Savvi Studio

Headless UI Pattern Overview

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

Core Principle

ALL LOGIC IN HOOKS. COMPONENTS JUST RENDER.

This single principle drives the entire architecture and ensures maximum flexibility, testability, and reusability.

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

Comparison

❌ 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

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

Status: Core Pattern
Last Updated: 2024-12-01