Savvi Studio

Headless UI Pattern Benefits

Parent: Headless UI Architecture
Last Updated: 2024-12-01

Overview

The headless UI pattern provides significant advantages over traditional component architectures by completely separating business logic from presentation.

1. Framework Agnostic

Benefit: The same hook works across different frameworks.

Example

// Define hook once
function useFeature({ id }: Options) {
    const [data, setData] = useState();
    const [loading, setLoading] = useState(true);
    
    useEffect(() => {
        fetchData(id).then(setData).finally(() => setLoading(false));
    }, [id]);
    
    return { data, loading, refresh: () => fetchData(id) };
}

// Use in React
function ReactComponent() {
    const { data, loading } = useFeature({ id: '123' });
    return <div>{data}</div>;
}

// Use in Vue (Composition API)
function VueComponent() {
    const { data, loading } = useFeature({ id: '123' });
    return { data, loading };
}

// Use in Svelte
function SvelteComponent() {
    const feature = useFeature({ id: '123' });
    // Svelte reactive statements
}

// Use in Solid.js
function SolidComponent() {
    const feature = useFeature({ id: '123' });
    return <div>{feature.data}</div>;
}

Impact: Write logic once, use everywhere. No framework lock-in.

2. UI Library Flexibility

Benefit: Use any UI library with the same business logic.

Example

// Same hook for all
function useUserProfile({ userId }: Options) {
    const { data, isLoading, error } = useQuery(['user', userId]);
    const handleUpdate = useCallback(() => { /* update logic */ }, []);
    return { user: data, isLoading, error, handleUpdate };
}

// Material UI version
function ProfileMUI({ userId }: Props) {
    const { user, isLoading, handleUpdate } = useUserProfile({ userId });
    return (
        <Card>
            <CardContent>{user?.name}</CardContent>
            <Button onClick={handleUpdate}>Update</Button>
        </Card>
    );
}

// Chakra UI version
function ProfileChakra({ userId }: Props) {
    const { user, isLoading, handleUpdate } = useUserProfile({ userId });
    return (
        <Box>
            <Text>{user?.name}</Text>
            <Button onClick={handleUpdate}>Update</Button>
        </Box>
    );
}

// Headless UI version
function ProfileHeadless({ userId }: Props) {
    const { user, isLoading, handleUpdate } = useUserProfile({ userId });
    return (
        <div>
            <span>{user?.name}</span>
            <button onClick={handleUpdate}>Update</button>
        </div>
    );
}

// Ant Design version
function ProfileAntD({ userId }: Props) {
    const { user, isLoading, handleUpdate } = useUserProfile({ userId });
    return (
        <Card>
            <p>{user?.name}</p>
            <AntButton onClick={handleUpdate}>Update</AntButton>
        </Card>
    );
}

Impact: Swap UI libraries without rewriting logic. Design system changes become trivial.

3. Easy Testing

Benefit: Test business logic without rendering DOM.

Hook Testing (Simple)

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

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

Component Testing (Simple)

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');
    });
});

Impact: Tests run faster (no DOM), easier to write, better coverage of business logic.

4. Composability

Benefit: Hooks can compose other hooks for complex functionality.

Example

// Small, focused hooks
function useDataFetch(id: string) {
    return useQuery(['data', id]);
}

function useValidation(data: any) {
    return useMemo(() => validateData(data), [data]);
}

function usePermissions(id: string) {
    return useQuery(['permissions', id]);
}

// Composed hook
function useComplexFeature({ id }: Options) {
    // Compose multiple hooks
    const { data, isLoading } = useDataFetch(id);
    const validation = useValidation(data);
    const { canEdit, canDelete } = usePermissions(id);
    
    const handleSave = useCallback(async () => {
        if (!validation.isValid) return;
        if (!canEdit) throw new Error('No permission');
        await saveData(data);
    }, [data, validation, canEdit]);
    
    return {
        data: validation.validData,
        isLoading,
        canEdit,
        canDelete,
        handleSave,
        errors: validation.errors
    };
}

Impact: Build complex functionality from simple, reusable pieces. Easier to understand and maintain.

5. Clear Separation of Concerns

Benefit: Business logic and presentation are completely separate.

Traditional (Mixed)

// Everything mixed together
function UserProfile({ userId }: Props) {
    const [user, setUser] = useState();
    const [loading, setLoading] = useState(true);
    const [editing, setEditing] = useState(false);
    
    useEffect(() => {
        fetch(`/api/users/${userId}`)
            .then(res => res.json())
            .then(setUser)
            .finally(() => setLoading(false));
    }, [userId]);
    
    const handleSave = async () => {
        setLoading(true);
        try {
            await fetch(`/api/users/${userId}`, {
                method: 'PUT',
                body: JSON.stringify(user)
            });
            toast.success('Saved!');
            setEditing(false);
        } catch (e) {
            toast.error('Failed');
        } finally {
            setLoading(false);
        }
    };
    
    if (loading) return <Spinner />;
    return <Card>...</Card>;
}

Problems: Logic and UI mixed, hard to test, can't reuse logic.

Headless (Separated)

// Hook: ALL logic
function useUserProfile({ userId }: Options) {
    const { data: user, isLoading } = useQuery(['user', userId]);
    const [editing, setEditing] = useState(false);
    const mutation = useMutation(updateUser);
    
    const handleSave = useCallback(async (updates) => {
        await mutation.mutateAsync({ userId, updates });
        toast.success('Saved!');
        setEditing(false);
    }, [userId, mutation]);
    
    return { user, isLoading, editing, setEditing, handleSave };
}

// Component: Just rendering
function UserProfile({ userId }: Props) {
    const { user, isLoading, editing, handleSave } = 
        useUserProfile({ userId });
    
    if (isLoading) return <Spinner />;
    return <Card>...</Card>;
}

Benefits: Clear responsibilities, easy to test, logic reusable.

6. Performance Optimization

Benefit: Optimize logic and rendering independently.

Example

// Optimize hook
function useOptimizedFeature({ id }: Options) {
    // Memoize expensive computations
    const computed = useMemo(() => expensiveCalculation(data), [data]);
    
    // Debounce updates
    const debouncedUpdate = useDebouncedCallback((value) => {
        updateAPI(value);
    }, 500);
    
    // Cache results
    const { data } = useQuery(['feature', id], {
        staleTime: 5 * 60 * 1000  // 5 minutes
    });
    
    return { data, computed, debouncedUpdate };
}

// Optimize component separately
const MemoizedComponent = memo(function FeatureComponent({ id }: Props) {
    const hook = useOptimizedFeature({ id });
    return <Display {...hook} />;
});

Impact: Optimize where it matters. Hook optimizations benefit all UIs using it.

7. Better Developer Experience

Benefit: Clearer code structure and easier debugging.

Type Safety

// Hook has clear contract
export interface UseFeatureReturn {
    data: Data | undefined;
    isLoading: boolean;
    error: Error | null;
    actions: {
        refresh: () => void;
        update: (data: Data) => Promise<void>;
    };
}

// TypeScript knows exactly what's available
function MyComponent() {
    const { data, actions } = useFeature({ id: '123' });
    //     ^       ^
    //     Fully typed!
    
    actions.refresh();  // ✅ TypeScript validates this
    actions.delete();   // ❌ TypeScript error: doesn't exist
}

Debugging

// Debug hook independently
const feature = useFeature({ id: '123' });
console.log('Hook state:', feature);  // See all logic state

// Debug component separately
function FeatureComponent({ id }: Props) {
    const feature = useFeature({ id });
    console.log('Rendering with:', feature);  // See what component receives
    return <Display {...feature} />;
}

Impact: Faster development, fewer bugs, easier debugging.

Summary

Benefit Traditional Headless
Framework Support Single framework Any framework
UI Library Locked in Any library
Testing Requires DOM No DOM needed
Reusability Copy-paste Import and use
Composability Limited Full composition
Maintainability Mixed concerns Clear separation
Performance Coupled Independent optimization
DX Complex Clean and clear

Last Updated: 2024-12-01