Savvi Studio

Headless UI Architecture Pattern

Pattern: Separation of logic (hooks) from presentation (components)
Inspired by: TanStack libraries (Table, Query, Form, etc.)
Status: ✅ Proven Pattern
Last Updated: 2024-12-01

Core Principle

ALL LOGIC IN HOOKS. COMPONENTS JUST RENDER.

This architectural pattern provides maximum flexibility, testability, and reusability by completely separating business logic from UI presentation.

Architecture Layers

┌─────────────────────────────┐
│  Components (Thin Wrappers) │
│  - Just rendering           │
│  - No logic                 │
└──────────┬──────────────────┘
           │ useHook()
           ├─ data
           ├─ states
           ├─ computed
           ├─ actions
┌──────────┴──────────────────┐
│  Hooks (ALL Logic)          │
│  - Business logic           │
│  - State management         │
│  - Data fetching            │
│  - Computed values          │
│  - Event handlers           │
└──────────┬──────────────────┘
           │ API calls
┌──────────┴──────────────────┐
│  API Layer (tRPC/REST)      │
└─────────────────────────────┘

Hook Contract Pattern

Every hook follows a consistent interface pattern:

// 1. Options (input configuration)
export interface UseFeatureOptions {
    id: string;
    mode?: 'compact' | 'full';
    onComplete?: () => void;
}

// 2. Return value (exposed API)
export interface UseFeatureReturn {
    data: Data | undefined;
    isLoading: boolean;
    error: Error | null;
    computedValue: string;
    actions: {
        refresh: () => void;
        update: (data: Data) => Promise<void>;
    };
}

// 3. Implementation (ALL logic here)
export function useFeature(
    options: UseFeatureOptions
): UseFeatureReturn {
    // Business logic
    // State management
    // Data fetching
    // Computed values
    // Event handlers
    
    return {
        data,
        isLoading,
        error,
        computedValue,
        actions: { refresh, update }
    };
}

Component Pattern

Components are thin wrappers that just render:

export function FeatureComponent({ id, mode }: Props) {
    // 1. Get EVERYTHING from hook
    const { data, isLoading, computedValue, actions } = 
        useFeature({ id, mode });
    
    // 2. Just render (conditional rendering OK)
    if (isLoading) return <Skeleton />;
    if (!data) return <EmptyState />;
    
    return (
        <Card title={computedValue}>
            <Content data={data} />
            <Button onClick={actions.refresh}>Refresh</Button>
        </Card>
    );
}

What components CAN do:

  • Call hooks
  • Conditional rendering
  • Map data to JSX
  • Pass hook actions to event handlers
  • Compose other components

What components CANNOT do:

  • Business logic
  • State management
  • Data fetching
  • Computed values
  • Complex event handlers

Benefits

1. Framework Agnostic

The same hook works across different frameworks:

// React
const feature = useFeature({ id });

// Vue Composition API
const feature = useFeature({ id });

// Svelte
const feature = useFeature({ id });

// Solid.js
const feature = useFeature({ id });

2. UI Library Flexibility

Use any UI library with the same logic:

// Material UI
function FeatureMUI({ id }: Props) {
    const { data, actions } = useFeature({ id });
    return <Card><Button onClick={actions.refresh}>...</Button></Card>;
}

// Chakra UI
function FeatureChakra({ id }: Props) {
    const { data, actions } = useFeature({ id });
    return <Box><Button onClick={actions.refresh}>...</Button></Box>;
}

// Headless UI
function FeatureHeadless({ id }: Props) {
    const { data, actions } = useFeature({ id });
    return <div><button onClick={actions.refresh}>...</button></div>;
}

3. Easy Testing

Test logic without DOM:

import { renderHook } from '@testing-library/react-hooks';

test('validates data', async () => {
    const { result } = renderHook(() => useFeature({ id: '123' }));
    
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.data).toBeDefined();
});

4. Composability

Hooks can compose other hooks:

export function useComplexFeature(options: Options) {
    const data = useDataFetch(options.id);
    const validation = useValidation(data);
    const permissions = usePermissions(options.id);
    
    return {
        data: validation.validData,
        canEdit: permissions.canEdit,
        save: () => { /* logic */ }
    };
}

Comparison: Monolithic vs Headless

❌ Monolithic (Anti-Pattern)

function FeatureComponent({ id }: Props) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        // Fetch logic mixed with component
        fetchData(id).then(setData).catch(setError).finally(() => setLoading(false));
    }, [id]);
    
    const handleSave = async () => {
        // Business logic in component
        try {
            await saveData(data);
            toast.success('Saved');
        } catch (e) {
            toast.error('Failed');
        }
    };
    
    if (loading) return <Loading />;
    return <Card>...</Card>;
}

Problems:

  • Logic tied to React
  • Can't test without rendering
  • Can't reuse logic
  • Hard to maintain
  • Mixed concerns

✅ Headless (Correct Pattern)

// Hook: ALL logic (reusable, testable)
function useFeature({ id }: Options) {
    const { data, isLoading, error } = useQuery(['feature', id]);
    
    const handleSave = useCallback(async () => {
        try {
            await saveData(data);
            toast.success('Saved');
        } catch (e) {
            toast.error('Failed');
        }
    }, [data]);
    
    return { data, isLoading, error, handleSave };
}

// Component: Just rendering
function FeatureComponent({ id }: Props) {
    const { data, isLoading, handleSave } = useFeature({ id });
    
    if (isLoading) return <Loading />;
    return <Card><Button onClick={handleSave}>Save</Button></Card>;
}

Benefits:

  • Logic extracted and reusable
  • Easy to test separately
  • Clear separation of concerns
  • Framework agnostic
  • Maintainable

Testing Strategy

Test Type Target Method Focus
Unit Hooks renderHook() Business logic, state, computed values
Component Components render() + RTL Rendering, accessibility
Integration Both render() + userEvent User flows, interactions

Testing Hooks (Unit Tests)

import { renderHook, act, waitFor } from '@testing-library/react-hooks';

describe('useFeature', () => {
    it('fetches data on mount', async () => {
        const { result } = renderHook(() => useFeature({ id: '123' }));
        
        expect(result.current.isLoading).toBe(true);
        
        await waitFor(() => {
            expect(result.current.isLoading).toBe(false);
        });
        
        expect(result.current.data).toBeDefined();
    });
    
    it('handles errors gracefully', async () => {
        mockAPI.fetchFeature.mockRejectedValue(new Error('Failed'));
        
        const { result } = renderHook(() => useFeature({ id: '123' }));
        
        await waitFor(() => {
            expect(result.current.error).toBeTruthy();
        });
    });
    
    it('updates data correctly', async () => {
        const { result } = renderHook(() => useFeature({ id: '123' }));
        
        await waitFor(() => result.current.data);
        
        act(() => {
            result.current.actions.update({ name: 'New Name' });
        });
        
        expect(result.current.data.name).toBe('New Name');
    });
});

Testing Components (Visual Tests)

import { render, screen } from '@testing-library/react';

describe('FeatureComponent', () => {
    it('renders loading state', () => {
        render(<FeatureComponent id="123" />);
        expect(screen.getByTestId('loading')).toBeInTheDocument();
    });
    
    it('renders data when loaded', async () => {
        render(<FeatureComponent id="123" />);
        await screen.findByText('Feature Name');
        expect(screen.getByText('Feature Name')).toBeInTheDocument();
    });
    
    it('is accessible', async () => {
        const { container } = render(<FeatureComponent id="123" />);
        await screen.findByRole('button');
        
        const results = await axe(container);
        expect(results).toHaveNoViolations();
    });
});

File Organization

src/
├── hooks/
│   ├── useFeature.ts           # Business logic
│   ├── useDataFetch.ts         # Data fetching
│   └── useValidation.ts        # Validation logic
├── components/
│   ├── Feature.tsx             # UI component
│   ├── FeatureCard.tsx         # Sub-component
│   └── FeatureList.tsx         # List component
└── tests/
    ├── hooks/
    │   ├── useFeature.test.ts  # Hook tests
    │   └── useValidation.test.ts
    └── components/
        └── Feature.test.tsx     # Component tests

Principles:

  • Hooks in hooks/ directory
  • Components in components/ directory
  • Tests mirror structure
  • Clear naming convention

Real-World Example

Maintenance Mode System

// Hook: ALL maintenance logic
export function useMaintenanceStatus(options?: Options) {
    const { data: status } = trpc.maintenance.getStatus.useQuery(
        undefined,
        { refetchInterval: options?.pollInterval ?? 5000 }
    );
    
    const progress = useMemo(() => {
        if (!status?.startedAt || !status?.estimatedDuration) return 0;
        const elapsed = (Date.now() - status.startedAt.getTime()) / 1000;
        return Math.min((elapsed / status.estimatedDuration) * 100, 100);
    }, [status]);
    
    const timeRemaining = useMemo(() => {
        if (!status?.estimatedDuration) return null;
        const elapsed = (Date.now() - status.startedAt.getTime()) / 1000;
        return Math.max(status.estimatedDuration - elapsed, 0);
    }, [status]);
    
    useEffect(() => {
        if (!status?.isActive && options?.autoRedirect) {
            router.push(options.redirectPath ?? '/');
        }
    }, [status?.isActive]);
    
    return {
        status,
        progress,
        timeRemaining,
        refetch: () => queryClient.invalidateQueries(['maintenance'])
    };
}

// Component: Just rendering
export function MaintenancePage() {
    const { status, progress, timeRemaining } = useMaintenanceStatus({
        autoRedirect: true
    });
    
    if (!status?.isActive) return null;
    
    return (
        <div className="maintenance-page">
            <h1>{status.reason}</h1>
            <ProgressBar value={progress} />
            {timeRemaining && <p>{timeRemaining}s remaining</p>}
        </div>
    );
}

Best Practices

DO ✅

  • Put ALL logic in hooks
  • Keep components thin (rendering only)
  • Return structured data from hooks
  • Use TypeScript for hook contracts
  • Test hooks independently
  • Compose hooks when needed
  • Follow naming conventions (use*)
  • Document hook options and returns
  • Handle loading and error states in hooks
  • Make hooks reusable across projects

DON'T ❌

  • Put business logic in components
  • Mix concerns
  • Make components too smart
  • Forget to test hooks
  • Create monolithic hooks (compose instead)
  • Ignore error handling
  • Skip TypeScript types
  • Couple hooks to specific UI libraries
  • Assume framework-specific features
  • Make hooks do too much (single responsibility)

Migration Guide

Converting Monolithic to Headless

  1. Extract State and Logic

    // Before: Component with logic
    function MyComponent() {
        const [data, setData] = useState();
        const handleClick = () => { /* logic */ };
        return <div>...</div>;
    }
    
    // After: Extract to hook
    function useMyFeature() {
        const [data, setData] = useState();
        const handleClick = () => { /* logic */ };
        return { data, handleClick };
    }
    
    function MyComponent() {
        const { data, handleClick } = useMyFeature();
        return <div onClick={handleClick}>...</div>;
    }
    
  2. Test Hook First

    test('hook logic works', () => {
        const { result } = renderHook(() => useMyFeature());
        // Test logic
    });
    
  3. Update Component

    // Component now just renders
    function MyComponent() {
        const hook = useMyFeature();
        return <UI data={hook.data} />;
    }
    

Summary

Aspect Headless Pattern
Logic In hooks
Rendering In components
Testing Separate (hooks + components)
Reusability Maximum (hooks work everywhere)
Maintainability High (clear separation)
Flexibility Maximum (any UI library)

Result: Clean separation, easy testing, maximum reusability!

Projects Using This Pattern


Pattern Type: Architectural
Complexity: Medium
Benefits: High testability, reusability, flexibility
Tradeoffs: More files (hooks separate from components)
Last Updated: 2024-12-01