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
-
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>; } -
Test Hook First
test('hook logic works', () => { const { result } = renderHook(() => useMyFeature()); // Test logic }); -
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!
Related Documentation
- Testing Patterns - Testing strategies for hooks and components
- React Patterns - React-specific patterns
- Component Library Guide - Building reusable components
Projects Using This Pattern
- Graph Reflexive Kernel - Original implementation
- Maintenance Mode - State management and UI
- Graph Core Hooks - Graph data access
Pattern Type: Architectural
Complexity: Medium
Benefits: High testability, reusability, flexibility
Tradeoffs: More files (hooks separate from components)
Last Updated: 2024-12-01