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 |
Related Documentation
- Pattern Overview - Core concepts and principles
- Testing Strategy - How to test this pattern
- Best Practices - Guidelines and recommendations
- Examples - Real-world implementations
Last Updated: 2024-12-01