React Hooks Composition Patterns
Overview
Advanced patterns for composing React hooks to create maintainable, performant, and type-safe custom hooks. Covers SWR integration, debounced search, memoized contexts, and state machine patterns.
Key Concepts:
- Conditional SWR fetching with null keys
- Debounced state with dual loading indicators
- Memoized context providers to prevent re-renders
- State machine pattern for predictable UI states
- Pure helper functions for testability
Pattern 1: SWR Hook Composition with Conditional Fetching
The Pattern
Compose SWR with conditional logic, data transformation, and pure helper functions.
typescriptimport useSWR from 'swr'; import { useMemo } from 'react'; // Type definitions interface MapboxSuggestion { name: string; mapbox_id: string; context?: { country?: { country_code: string; }; }; } interface MapboxSuggestResponse { suggestions: MapboxSuggestion[]; } interface LocationSuggestion { id: string; displayName: string; region: string; } // Custom hook with conditional fetching export function useMapboxLocationSuggestions( inputValue: string | null | undefined ) { const sessionId = useSessionId(); // Conditional SWR key - null disables fetching const { data, error, isLoading } = useSWR<MapboxSuggestResponse>( // Key is null (no fetch) unless all conditions met sessionId && process.env.NEXT_PUBLIC_MAPBOX_API_KEY && isValidSearchQuery(inputValue) ? `https://api.mapbox.com/search/searchbox/v1/suggest?q=${encodeURIComponent(inputValue!)}&session_token=${sessionId}&access_token=${process.env.NEXT_PUBLIC_MAPBOX_API_KEY}` : null ); // Transform data with useMemo for performance const mappedData = useMemo(() => { if (!data) return undefined; return data.suggestions .filter(isUsState) .map(formatMapboxLocation); }, [data]); return { data: mappedData, error, isLoading }; } // Pure helper functions (outside component/hook) const isValidSearchQuery = ( value: string | null | undefined ): value is string => { return typeof value === 'string' && value.trim().length >= 2; }; const isUsState = (suggestion: MapboxSuggestion): boolean => { return suggestion.context?.country?.country_code === 'us'; }; const formatMapboxLocation = ( suggestion: MapboxSuggestion ): LocationSuggestion => ({ id: suggestion.mapbox_id, displayName: suggestion.name, region: 'US', });
Why This Works
Conditional Fetching:
- SWR doesn't fetch when key is
null - All conditions checked before constructing URL
- Type guard
isValidSearchQueryensures type safety - Prevents unnecessary API calls on empty input
Data Transformation:
useMemoprevents recomputation on every render- Dependency array
[data]only recomputes when data changes - Filter and map operations are pure and testable
Testability:
- Pure functions can be tested independently
- No React context needed for helper functions
- Type guards provide runtime validation
Common Triggers
Use this pattern when:
- "fetch data based on user input"
- "conditional API calls with SWR"
- "transform API response data"
- "prevent fetching on empty search"
- "type-safe data fetching hooks"
Pattern 2: Debounced Search with Dual Loading States
The Pattern
Combine debounced input with SWR fetching, tracking both debouncing and network loading states.
typescriptimport { useState } from 'react'; import useSWR from 'swr'; // Debounce hook function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState<T>(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; } // Search component with dual loading states export function SearchComponent() { const [searchInput, setSearchInput] = useState(''); const debouncedSearchInput = useDebounce(searchInput, 300); // Track if we're still debouncing (user is typing) const isDebouncing = searchInput !== debouncedSearchInput; // Fetch with debounced value const { data = [], isLoading } = useLocationSuggestions(debouncedSearchInput); // Combined loading state: debouncing OR fetching const suggestionsLoading = isDebouncing || isLoading; return ( <SearchInput value={searchInput} onChange={setSearchInput} loading={suggestionsLoading} suggestions={data} placeholder="Search locations..." /> ); }
Why This Works
Dual Loading States:
isDebouncing: Indicates user is still typingisLoading: Indicates network request in progress- Combined state provides smooth UX feedback
Performance:
- API calls only fire after 300ms of inactivity
- Reduces unnecessary requests while typing
- SWR caches results for instant display
User Experience:
- Loading indicator appears immediately on typing
- Prevents result flashing between debounce intervals
- Clear feedback during both input and fetch phases
Common Triggers
Use this pattern when:
- "debounced search with loading state"
- "prevent too many API calls while typing"
- "show loading indicator during debounce"
- "smooth search experience"
- "combine debounce with data fetching"
Pattern 3: Memoized Context Provider
The Pattern
Create context providers with memoized values to prevent unnecessary re-renders of consumers.
typescriptimport { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react'; // Context value type interface LocationContextValue { location: UserLocation | null; requestPreciseLocation: () => Promise<UserLocation | null>; } interface UserLocation { latitude: number; longitude: number; accuracy: 'coarse' | 'precise'; } // Create context with undefined default const UserLocationContext = createContext<LocationContextValue | undefined>( undefined ); // Provider component export const UserLocationProvider = ({ location: initialLocation = null, children }: { location?: UserLocation | null; children: ReactNode; }) => { const [preciseLocation, setPreciseLocation] = useState<UserLocation | null>(null); // Stable callback reference with useCallback const requestPreciseLocation = useCallback(async () => { try { const coords = await getUserCoordinates(); const location = coords ? { latitude: coords.latitude, longitude: coords.longitude, accuracy: 'precise' as const } : null; setPreciseLocation(location); return location; } catch (error) { console.error('Failed to get precise location:', error); return null; } }, []); // No dependencies - function is stable // Memoized context value prevents unnecessary re-renders const contextValue = useMemo<LocationContextValue>(() => ({ location: preciseLocation || initialLocation, requestPreciseLocation, }), [initialLocation, preciseLocation, requestPreciseLocation]); return ( <UserLocationContext.Provider value={contextValue}> {children} </UserLocationContext.Provider> ); }; // Type-safe hook for consuming context export const useUserLocation = (): LocationContextValue => { const context = useContext(UserLocationContext); if (context === undefined) { throw new Error('useUserLocation must be used within UserLocationProvider'); } return context; }; // Helper function (can be in separate file) async function getUserCoordinates(): Promise<GeolocationCoordinates | null> { return new Promise((resolve) => { navigator.geolocation.getCurrentPosition( (position) => resolve(position.coords), () => resolve(null), { enableHighAccuracy: true } ); }); }
Why This Works
Memoization Prevents Re-renders:
useMemofor context value object- Only recreates when dependencies change
- Consumers only re-render when values actually change
Stable References:
useCallbackensuresrequestPreciseLocationreference is stable- No dependencies means function never changes
- Internal state updates don't recreate function
Type Safety:
- Context hook throws if used outside provider
- TypeScript enforces correct usage
- Clear error messages for misuse
Best Practices:
- Context value is an object with multiple properties
- Provider manages both props and internal state
- Custom hook abstracts context consumption
Common Triggers
Use this pattern when:
- "context causing too many re-renders"
- "optimize context provider performance"
- "stable callback in context"
- "memoized context value"
- "prevent unnecessary renders from context"
Pattern 4: State Machine for UI States
The Pattern
Use discriminated unions and state machines for predictable UI state management.
typescriptimport { useState } from 'react'; // Discriminated union for request states type RequestState = 'idle' | 'pending' | 'success' | 'error'; // State-specific configuration const stateConfig: Record<RequestState, { label: string; disabled: boolean; variant: 'primary' | 'success' | 'danger'; }> = { idle: { label: 'Click to start', disabled: false, variant: 'primary', }, pending: { label: 'Loading...', disabled: true, variant: 'primary', }, success: { label: 'Complete!', disabled: false, variant: 'success', }, error: { label: 'Failed - try again', disabled: false, variant: 'danger', }, }; // Component using state machine export function AsyncButton({ onClick }: { onClick: () => Promise<void>; }) { const [state, setState] = useState<RequestState>('idle'); const handleClick = async () => { // State transition: idle/error → pending setState('pending'); try { await onClick(); // State transition: pending → success setState('success'); // Auto-reset after 2 seconds setTimeout(() => setState('idle'), 2000); } catch (error) { // State transition: pending → error setState('error'); } }; const config = stateConfig[state]; return ( <button onClick={handleClick} disabled={config.disabled} className={`btn btn-${config.variant}`} > {config.label} </button> ); }
Advanced: Tagged Union with Data
For more complex states with state-specific data:
typescript// Tagged union with discriminated state type AsyncState<T, E = Error> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: E }; function useAsyncOperation<T>( operation: () => Promise<T> ): { state: AsyncState<T>; execute: () => Promise<void>; reset: () => void; } { const [state, setState] = useState<AsyncState<T>>({ status: 'idle' }); const execute = async () => { setState({ status: 'loading' }); try { const data = await operation(); setState({ status: 'success', data }); } catch (error) { setState({ status: 'error', error: error instanceof Error ? error : new Error('Unknown error') }); } }; const reset = () => { setState({ status: 'idle' }); }; return { state, execute, reset }; } // Usage in component export function DataFetchButton() { const { state, execute, reset } = useAsyncOperation(async () => { const response = await fetch('/api/data'); return response.json(); }); return ( <div> <button onClick={execute} disabled={state.status === 'loading'}> Fetch Data </button> {state.status === 'loading' && <Spinner />} {state.status === 'success' && ( <div> <pre>{JSON.stringify(state.data, null, 2)}</pre> <button onClick={reset}>Reset</button> </div> )} {state.status === 'error' && ( <div className="error"> {state.error.message} <button onClick={reset}>Try Again</button> </div> )} </div> ); }
Why This Works
Predictable State Transitions:
- Explicit states prevent impossible states
- Clear state transition logic
- TypeScript ensures all states handled
Type Safety:
- Discriminated unions enable exhaustive checking
- State-specific data is type-safe
- Compiler catches missing state handlers
Maintainability:
- State configuration in one place
- Easy to add new states
- Clear separation of state and UI
Common Triggers
Use this pattern when:
- "manage async operation states"
- "predictable UI state machine"
- "loading, success, error states"
- "prevent impossible states"
- "type-safe state management"
Advanced Composition: Combining Patterns
Complete Search Component
Combining debounced search, SWR fetching, and state machine:
typescriptimport { useState, useMemo } from 'react'; import useSWR from 'swr'; type SearchState = 'idle' | 'debouncing' | 'fetching' | 'success' | 'error'; export function AdvancedSearch() { const [searchInput, setSearchInput] = useState(''); const debouncedInput = useDebounce(searchInput, 300); // Determine current state const isDebouncing = searchInput !== debouncedInput; // Conditional SWR fetching const { data, error, isLoading } = useSWR( debouncedInput.length >= 2 ? `/api/search?q=${encodeURIComponent(debouncedInput)}` : null ); // Compute search state const searchState = useMemo<SearchState>(() => { if (error) return 'error'; if (isDebouncing) return 'debouncing'; if (isLoading) return 'fetching'; if (data) return 'success'; return 'idle'; }, [isDebouncing, isLoading, data, error]); // State-specific UI const showSpinner = searchState === 'debouncing' || searchState === 'fetching'; const showResults = searchState === 'success'; const showError = searchState === 'error'; return ( <div> <input type="text" value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="Search..." /> {showSpinner && <Spinner />} {showResults && ( <SearchResults results={data.results} /> )} {showError && ( <ErrorMessage>Failed to fetch results</ErrorMessage> )} </div> ); }
Anti-Patterns to Avoid
❌ Don't: Create New Objects in Render
typescript// BAD: New object on every render causes unnecessary re-renders function MyProvider({ children }) { const [state, setState] = useState(null); return ( <MyContext.Provider value={{ state, setState }}> {children} </MyContext.Provider> ); }
typescript// GOOD: Memoize the context value function MyProvider({ children }) { const [state, setState] = useState(null); const value = useMemo(() => ({ state, setState }), [state]); return ( <MyContext.Provider value={value}> {children} </MyContext.Provider> ); }
❌ Don't: Forget SWR Conditional Fetching
typescript// BAD: Fetches even with empty input function useSearch(query: string) { const { data } = useSWR(`/api/search?q=${query}`); return data; }
typescript// GOOD: Only fetch when query is valid function useSearch(query: string) { const { data } = useSWR( query.trim().length >= 2 ? `/api/search?q=${query}` : null ); return data; }
❌ Don't: Ignore Debounce vs Loading State
typescript// BAD: Only shows loading during network request function Search() { const [input, setInput] = useState(''); const debounced = useDebounce(input, 300); const { data, isLoading } = useSWR(`/api?q=${debounced}`); // Spinner disappears while debouncing! return <>{isLoading && <Spinner />}</>; }
typescript// GOOD: Show loading during both debounce and fetch function Search() { const [input, setInput] = useState(''); const debounced = useDebounce(input, 300); const { data, isLoading } = useSWR(`/api?q=${debounced}`); const isDebouncing = input !== debounced; const loading = isDebouncing || isLoading; return <>{loading && <Spinner />}</>; }
❌ Don't: Use String States Without Type Safety
typescript// BAD: Easy to typo, no autocomplete function Component() { const [status, setStatus] = useState('idel'); // Typo! if (status === 'idle') { // Won't match return <div>Ready</div>; } }
typescript// GOOD: Use discriminated union type Status = 'idle' | 'loading' | 'success' | 'error'; function Component() { const [status, setStatus] = useState<Status>('idle'); if (status === 'idle') { // Type-safe return <div>Ready</div>; } }
Best Practices Summary
- Conditional SWR Keys: Use
nullto prevent fetching when conditions aren't met - Memoize Transformations: Use
useMemofor expensive data transformations - Stable Callbacks: Use
useCallbackfor functions in context or dependencies - Memoize Context Values: Prevent unnecessary re-renders of context consumers
- Dual Loading States: Track both debouncing and network loading separately
- Pure Helper Functions: Extract logic outside components for testability
- Type-Safe States: Use discriminated unions for state machines
- Explicit State Transitions: Make state changes predictable and clear
Related Patterns
See the react-state-machine skill for more advanced state machine patterns with XState. See the react-advanced skill for React 19 platform features and rendering architecture — the React Compiler (which changes when manual memoization is needed), concurrent rendering, Actions, and context-optimization at scale.

