
React Hooks Composition
- 79 installs
- 63 repo stars
- Updated July 18, 2026
- bobmatnyc/claude-mpm-skills
react-hooks-composition is a reference skill for advanced React hooks composition, covering SWR integration, debounced search, memoized contexts, and type-safe custom hooks.
About
This skill is a reference for advanced React custom-hook composition patterns. It covers SWR integration with conditional keys, debounced search with dual loading states, memoized context providers, state-machine-style UI states, and pure helper functions for testability. A developer uses it when building data-fetching hooks, search interfaces, or performance-critical custom hooks.
- SWR custom hooks with conditional keys and data transformation
- Debounced search with dual loading indicators
- Memoized context providers and type-safe hook patterns
React Hooks Composition by the numbers
- 79 all-time installs (skills.sh)
- Ranked #1,119 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
react-hooks-composition capabilities & compatibility
- Capabilities
- react development · custom hooks · data fetching
- Use cases
- frontend · refactoring
- Runs
- Runs locally
- Pricing
- Free
What react-hooks-composition says it does
Advanced patterns for composing React hooks to create maintainable, performant, and type-safe custom hooks.
Conditional SWR fetching with null keys
Memoized context providers to prevent re-renders
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill react-hooks-compositionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 79 |
|---|---|
| repo stars | ★ 63 |
| Last updated | July 18, 2026 |
| Repository | bobmatnyc/claude-mpm-skills ↗ |
What it does
Reference for composing custom React hooks with SWR, debounced state, memoized contexts, and type-safe patterns.
Who is it for?
Developers building data-fetching hooks, search interfaces, or performance-critical custom hooks.
Skip if: React fundamentals (use react-core) or full XState state machines (use react-state-machine).
When should I use this skill?
Building data-fetching hooks, search interfaces, context providers, async UI states, or performance-critical components.
What you get
Composable, type-safe custom hooks with efficient data fetching and memoization.
By the numbers
- Documents named composition patterns including SWR, debounced search, memoized contexts, and state machines
Files
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.
import 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.
import { 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.
import { 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.
import { 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:
// 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:
import { 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
// 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>
);
}// 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
// BAD: Fetches even with empty input
function useSearch(query: string) {
const { data } = useSWR(`/api/search?q=${query}`);
return data;
}// 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
// 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 />}</>;
}// 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
// BAD: Easy to typo, no autocomplete
function Component() {
const [status, setStatus] = useState('idel'); // Typo!
if (status === 'idle') { // Won't match
return <div>Ready</div>;
}
}// 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
1. Conditional SWR Keys: Use null to prevent fetching when conditions aren't met 2. Memoize Transformations: Use useMemo for expensive data transformations 3. Stable Callbacks: Use useCallback for functions in context or dependencies 4. Memoize Context Values: Prevent unnecessary re-renders of context consumers 5. Dual Loading States: Track both debouncing and network loading separately 6. Pure Helper Functions: Extract logic outside components for testability 7. Type-Safe States: Use discriminated unions for state machines 8. 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.
References
{
"name": "react-hooks-composition",
"version": "1.0.1",
"category": "toolchain",
"toolchain": "javascript",
"framework": "react",
"tags": [
"react",
"hooks",
"composition",
"swr",
"performance",
"typescript",
"custom-hooks",
"useMemo",
"useCallback",
"context",
"debounce",
"state-machine"
],
"entry_point_tokens": 4200,
"full_tokens": 12500,
"author": "Claude MPM Team",
"license": "MIT",
"requires": [],
"related_skills": [
"react-core",
"react-state-machine",
"react-advanced"
],
"updated": "2026-06-15",
"source_path": "toolchains/javascript/frameworks/react/react-hooks-composition/SKILL.md",
"source": "https://github.com/bobmatnyc/claude-mpm",
"created": "2026-02-01",
"modified": "2026-06-15",
"maintainer": "Claude MPM Team",
"attribution_required": true,
"repository": "https://github.com/bobmatnyc/claude-mpm-skills",
"has_references": true,
"reference_files": [
"swr-patterns.md",
"performance-optimization.md",
"testing-patterns.md"
]
}
React Hooks Performance Optimization
Memoization Strategies
useMemo: Expensive Computations
function UserDashboard({ users }: { users: User[] }) {
// Expensive computation - only recalculate when users change
const statistics = useMemo(() => {
return {
totalUsers: users.length,
activeUsers: users.filter(u => u.active).length,
averageAge: users.reduce((sum, u) => sum + u.age, 0) / users.length,
topRegions: calculateTopRegions(users), // Expensive function
};
}, [users]);
return <StatisticsDisplay stats={statistics} />;
}useCallback: Stable Function References
function ParentComponent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState<Item[]>([]);
// Without useCallback, this function recreates on every render
// causing child components to re-render unnecessarily
const handleItemClick = useCallback((itemId: string) => {
console.log('Item clicked:', itemId);
// Function body can reference count without including it in deps
// if you only need the latest value
}, []); // Empty deps = function never changes
const handleItemUpdate = useCallback((itemId: string, newData: Partial<Item>) => {
setItems(prev => prev.map(item =>
item.id === itemId ? { ...item, ...newData } : item
));
}, []); // setItems is stable, so no deps needed
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
{/* These children won't re-render when count changes */}
{items.map(item => (
<ItemComponent
key={item.id}
item={item}
onClick={handleItemClick}
onUpdate={handleItemUpdate}
/>
))}
</div>
);
}
// Child component must be memoized to benefit
const ItemComponent = memo(function ItemComponent({
item,
onClick,
onUpdate,
}: {
item: Item;
onClick: (id: string) => void;
onUpdate: (id: string, data: Partial<Item>) => void;
}) {
return (
<div onClick={() => onClick(item.id)}>
{item.name}
</div>
);
});React.memo: Prevent Component Re-renders
Pattern: Memoize Expensive Components
interface ExpensiveChartProps {
data: DataPoint[];
config: ChartConfig;
}
// Without memo, re-renders every time parent re-renders
const ExpensiveChart = memo(function ExpensiveChart({
data,
config
}: ExpensiveChartProps) {
// Expensive rendering logic
return <ComplexChart data={data} config={config} />;
}, (prevProps, nextProps) => {
// Custom comparison function
return (
prevProps.data === nextProps.data &&
prevProps.config.theme === nextProps.config.theme
);
});Pattern: Stable Props with useMemo
function Dashboard() {
const [filters, setFilters] = useState<Filters>({});
const [data, setData] = useState<DataPoint[]>([]);
// Create stable config object
const chartConfig = useMemo(() => ({
theme: 'dark',
animation: true,
responsive: true,
}), []); // Config never changes
// Create stable filtered data
const filteredData = useMemo(() => {
return applyFilters(data, filters);
}, [data, filters]);
// ExpensiveChart only re-renders when data or config actually changes
return (
<ExpensiveChart
data={filteredData}
config={chartConfig}
/>
);
}Context Optimization
Pattern: Split Contexts by Update Frequency
// Fast-changing state
const UserInteractionContext = createContext<{
mousePosition: { x: number; y: number };
scrollPosition: number;
} | undefined>(undefined);
// Slow-changing state
const UserDataContext = createContext<{
user: User | null;
preferences: UserPreferences;
} | undefined>(undefined);
function AppProvider({ children }: { children: ReactNode }) {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [scrollPosition, setScrollPosition] = useState(0);
const [user, setUser] = useState<User | null>(null);
const [preferences, setPreferences] = useState<UserPreferences>({});
// Fast-changing value
const interactionValue = useMemo(() => ({
mousePosition,
scrollPosition,
}), [mousePosition, scrollPosition]);
// Slow-changing value
const dataValue = useMemo(() => ({
user,
preferences,
}), [user, preferences]);
return (
<UserDataContext.Provider value={dataValue}>
<UserInteractionContext.Provider value={interactionValue}>
{children}
</UserInteractionContext.Provider>
</UserDataContext.Provider>
);
}Pattern: Context Selectors with useSyncExternalStore
import { useSyncExternalStore } from 'react';
interface Store {
users: User[];
posts: Post[];
comments: Comment[];
}
class StoreManager {
private state: Store = { users: [], posts: [], comments: [] };
private listeners = new Set<() => void>();
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = () => this.state;
updateUsers(users: User[]) {
this.state = { ...this.state, users };
this.listeners.forEach(listener => listener());
}
}
const store = new StoreManager();
// Custom hook that only subscribes to users
function useUsers() {
const state = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
);
return state.users;
}
// Component only re-renders when users change, not posts or comments
function UserList() {
const users = useUsers();
return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}Lazy Initialization
Pattern: Lazy Initial State
function ExpensiveComponent({ userId }: { userId: string }) {
// BAD: Expensive computation runs on every render
const [data, setData] = useState(expensiveComputation(userId));
// GOOD: Expensive computation runs only once
const [data, setData] = useState(() => expensiveComputation(userId));
return <div>{data}</div>;
}Pattern: Lazy Component Loading
import { lazy, Suspense } from 'react';
// Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'));
const ComplexEditor = lazy(() => import('./ComplexEditor'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}Debouncing and Throttling
Pattern: Debounced Input
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;
}
// Usage
function SearchInput() {
const [input, setInput] = useState('');
const debouncedInput = useDebounce(input, 500);
// API call only fires 500ms after user stops typing
const { data } = useSWR(
debouncedInput ? `/api/search?q=${debouncedInput}` : null
);
return (
<input
value={input}
onChange={(e) => setInput(e.target.value)}
/>
);
}Pattern: Throttled Event Handler
function useThrottle<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const lastRan = useRef(Date.now());
return useCallback(
((...args) => {
const now = Date.now();
if (now - lastRan.current >= delay) {
callback(...args);
lastRan.current = now;
}
}) as T,
[callback, delay]
);
}
// Usage
function ScrollTracker() {
const handleScroll = useThrottle(() => {
console.log('Scroll position:', window.scrollY);
}, 200); // Max once per 200ms
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
return <div>Scroll me</div>;
}List Rendering Optimization
Pattern: Virtualized Lists
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ItemComponent item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}Pattern: Keyed Reconciliation
// BAD: Using index as key
function BadList({ items }: { items: Item[] }) {
return (
<div>
{items.map((item, index) => (
<div key={index}>{item.name}</div>
))}
</div>
);
}
// GOOD: Using stable unique ID
function GoodList({ items }: { items: Item[] }) {
return (
<div>
{items.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}Conditional Rendering Optimization
Pattern: Early Return
function UserProfile({ userId }: { userId: string | null }) {
const { data: user, isLoading } = useUser(userId);
// Early returns prevent unnecessary hook calls
if (!userId) {
return <div>Please select a user</div>;
}
if (isLoading) {
return <Spinner />;
}
if (!user) {
return <div>User not found</div>;
}
// Main render only when we have data
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}Custom Hook Optimization
Pattern: Composable Optimized Hooks
// Base hook with minimal dependencies
function useUserData(userId: string) {
const { data, error, isLoading } = useSWR(`/api/users/${userId}`);
return { data, error, isLoading };
}
// Derived hook with memoization
function useUserStats(userId: string) {
const { data: user } = useUserData(userId);
const stats = useMemo(() => {
if (!user) return null;
return {
totalPosts: user.posts.length,
totalLikes: user.posts.reduce((sum, p) => sum + p.likes, 0),
avgLikesPerPost: user.posts.length
? user.posts.reduce((sum, p) => sum + p.likes, 0) / user.posts.length
: 0,
};
}, [user]);
return stats;
}
// Composed hook for complete user profile
function useUserProfile(userId: string) {
const userData = useUserData(userId);
const userStats = useUserStats(userId);
return useMemo(() => ({
...userData,
stats: userStats,
}), [userData, userStats]);
}Profiling and Debugging
Pattern: Performance Monitoring
function usePerformanceMonitor(componentName: string) {
const renderCount = useRef(0);
const lastRenderTime = useRef(Date.now());
useEffect(() => {
renderCount.current += 1;
const now = Date.now();
const timeSinceLastRender = now - lastRenderTime.current;
console.log(`[${componentName}] Render #${renderCount.current}`, {
timeSinceLastRender,
timestamp: now,
});
lastRenderTime.current = now;
});
}
// Usage
function MyComponent() {
usePerformanceMonitor('MyComponent');
return <div>Content</div>;
}Pattern: Why Did You Render
function useWhyDidYouUpdate(name: string, props: Record<string, any>) {
const previousProps = useRef<Record<string, any>>();
useEffect(() => {
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changedProps: Record<string, { from: any; to: any }> = {};
allKeys.forEach(key => {
if (previousProps.current![key] !== props[key]) {
changedProps[key] = {
from: previousProps.current![key],
to: props[key],
};
}
});
if (Object.keys(changedProps).length > 0) {
console.log(`[${name}] Changed props:`, changedProps);
}
}
previousProps.current = props;
});
}
// Usage
function MyComponent(props: MyProps) {
useWhyDidYouUpdate('MyComponent', props);
return <div>Content</div>;
}SWR Patterns and Best Practices
Conditional Fetching Strategies
Pattern: Query Parameter Dependencies
function useUserData(userId: string | null, includeDetails: boolean) {
const { data, error, isLoading } = useSWR(
userId && includeDetails
? `/api/users/${userId}?details=true`
: userId
? `/api/users/${userId}`
: null
);
return { user: data, error, isLoading };
}Pattern: Authentication-Gated Fetching
function useProtectedResource(resourceId: string) {
const { user } = useAuth();
const { data, error } = useSWR(
user?.token
? [`/api/resources/${resourceId}`, user.token]
: null,
([url, token]) => fetch(url, {
headers: { Authorization: `Bearer ${token}` }
}).then(r => r.json())
);
return { data, error };
}Data Transformation Patterns
Pattern: Filtering and Mapping Response
function useActiveUsers() {
const { data, error, isLoading } = useSWR<User[]>('/api/users');
const activeUsers = useMemo(() => {
if (!data) return [];
return data
.filter(user => user.status === 'active')
.map(user => ({
id: user.id,
name: user.fullName,
email: user.emailAddress,
}));
}, [data]);
return { users: activeUsers, error, isLoading };
}Pattern: Normalized Data Structure
interface ApiUser {
id: string;
attributes: {
firstName: string;
lastName: string;
email: string;
};
}
interface NormalizedUser {
id: string;
firstName: string;
lastName: string;
email: string;
}
function useNormalizedUsers() {
const { data, error, isLoading } = useSWR<ApiUser[]>('/api/users');
const normalizedUsers = useMemo(() => {
if (!data) return [];
return data.map(user => ({
id: user.id,
...user.attributes,
}));
}, [data]);
return { users: normalizedUsers, error, isLoading };
}Pagination Patterns
Pattern: Infinite Loading with SWR
import useSWRInfinite from 'swr/infinite';
function useInfiniteUsers(pageSize = 20) {
const getKey = (pageIndex: number, previousPageData: any) => {
// Reached the end
if (previousPageData && !previousPageData.hasMore) return null;
// First page
return `/api/users?page=${pageIndex + 1}&limit=${pageSize}`;
};
const { data, error, size, setSize, isLoading } = useSWRInfinite(getKey);
const users = useMemo(() => {
return data ? data.flatMap(page => page.users) : [];
}, [data]);
const hasMore = data?.[data.length - 1]?.hasMore ?? false;
const loadMore = () => {
setSize(size + 1);
};
return {
users,
error,
isLoading,
hasMore,
loadMore,
};
}Error Handling Patterns
Pattern: Retry with Exponential Backoff
function useResilientFetch<T>(url: string | null) {
const { data, error, isLoading } = useSWR<T>(
url,
{
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
// Don't retry on 404
if (error.status === 404) return;
// Max 5 retries
if (retryCount >= 5) return;
// Exponential backoff
setTimeout(() => {
revalidate({ retryCount });
}, 1000 * Math.pow(2, retryCount));
}
}
);
return { data, error, isLoading };
}Pattern: Fallback Data
function useUserWithFallback(userId: string) {
const { data, error, isLoading } = useSWR(
`/api/users/${userId}`,
{
fallbackData: {
id: userId,
name: 'Loading...',
email: '',
}
}
);
return { user: data, error, isLoading };
}Optimistic Updates
Pattern: Optimistic Mutation
function useTodoList() {
const { data: todos, mutate } = useSWR<Todo[]>('/api/todos');
const addTodo = async (title: string) => {
const newTodo = {
id: `temp-${Date.now()}`,
title,
completed: false,
};
// Optimistically update local data
mutate([...(todos || []), newTodo], false);
try {
// Send request to API
const created = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
}).then(r => r.json());
// Update with server response
mutate();
} catch (error) {
// Rollback on error
mutate();
throw error;
}
};
return { todos, addTodo };
}Dependent Queries
Pattern: Sequential Data Fetching
function useUserProfile(userId: string) {
// First query
const { data: user } = useSWR(`/api/users/${userId}`);
// Second query depends on first
const { data: posts } = useSWR(
user?.id ? `/api/users/${user.id}/posts` : null
);
// Third query depends on second
const { data: comments } = useSWR(
posts?.length ? `/api/posts/${posts[0].id}/comments` : null
);
return { user, posts, comments };
}Polling and Revalidation
Pattern: Real-time Updates
function useLiveData(endpoint: string, intervalMs = 5000) {
const { data, error, isLoading } = useSWR(
endpoint,
{
refreshInterval: intervalMs,
revalidateOnFocus: true,
revalidateOnReconnect: true,
}
);
return { data, error, isLoading };
}Pattern: Conditional Polling
function useConditionalPolling(enabled: boolean) {
const { data, error } = useSWR(
'/api/status',
{
refreshInterval: enabled ? 1000 : 0,
}
);
return { data, error };
}Type-Safe SWR Keys
Pattern: Typed SWR Keys
type ApiKey =
| ['user', string]
| ['posts', { userId: string; page: number }]
| ['comments', string];
function useTypedSWR<T>(key: ApiKey) {
return useSWR<T>(key, {
fetcher: async (key) => {
const [resource, params] = key;
switch (resource) {
case 'user':
return fetch(`/api/users/${params}`).then(r => r.json());
case 'posts':
return fetch(
`/api/posts?userId=${params.userId}&page=${params.page}`
).then(r => r.json());
case 'comments':
return fetch(`/api/comments/${params}`).then(r => r.json());
}
}
});
}
// Usage
const { data: user } = useTypedSWR<User>(['user', '123']);
const { data: posts } = useTypedSWR<Post[]>(['posts', { userId: '123', page: 1 }]);Performance Optimization
Pattern: Deduplicate Requests
function useDeduplicatedFetch<T>(url: string) {
const { data, error, isLoading } = useSWR<T>(
url,
{
dedupingInterval: 2000, // Deduplicate requests within 2s
}
);
return { data, error, isLoading };
}Pattern: Prefetching
import { mutate } from 'swr';
function prefetchUser(userId: string) {
// Prefetch user data
mutate(
`/api/users/${userId}`,
fetch(`/api/users/${userId}`).then(r => r.json())
);
}
// Usage in component
function UserList({ users }: { users: User[] }) {
return (
<div>
{users.map(user => (
<div
key={user.id}
onMouseEnter={() => prefetchUser(user.id)}
>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</div>
))}
</div>
);
}Testing React Hooks
Testing Custom Hooks with React Testing Library
Pattern: Basic Hook Testing
import { renderHook, waitFor } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('should debounce value updates', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{
initialProps: { value: 'initial', delay: 500 }
}
);
expect(result.current).toBe('initial');
// Update value
rerender({ value: 'updated', delay: 500 });
// Value should not change immediately
expect(result.current).toBe('initial');
// Fast-forward time
jest.advanceTimersByTime(500);
// Now value should be updated
expect(result.current).toBe('updated');
});
it('should cancel previous debounce on rapid updates', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'first' } }
);
rerender({ value: 'second' });
jest.advanceTimersByTime(300);
rerender({ value: 'third' });
jest.advanceTimersByTime(300);
// Should still be initial
expect(result.current).toBe('first');
// Complete the debounce
jest.advanceTimersByTime(200);
// Should be final value
expect(result.current).toBe('third');
});
});Pattern: Testing SWR Hooks
import { renderHook, waitFor } from '@testing-library/react';
import { SWRConfig } from 'swr';
import { useUserData } from './useUserData';
// Mock fetch
global.fetch = jest.fn();
describe('useUserData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should fetch user data', async () => {
const mockUser = { id: '1', name: 'John Doe', email: 'john@example.com' };
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map() }}>
{children}
</SWRConfig>
);
const { result } = renderHook(() => useUserData('1'), { wrapper });
// Initially loading
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
// Wait for data
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
});
it('should not fetch when userId is null', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map() }}>
{children}
</SWRConfig>
);
const { result } = renderHook(() => useUserData(null), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
});
it('should handle errors', async () => {
const mockError = new Error('Network error');
(global.fetch as jest.Mock).mockRejectedValueOnce(mockError);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map() }}>
{children}
</SWRConfig>
);
const { result } = renderHook(() => useUserData('1'), { wrapper });
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.data).toBeUndefined();
});
});Pattern: Testing Context Hooks
import { renderHook, act } from '@testing-library/react';
import { UserLocationProvider, useUserLocation } from './UserLocationContext';
describe('useUserLocation', () => {
it('should provide initial location', () => {
const initialLocation = {
latitude: 37.7749,
longitude: -122.4194,
accuracy: 'coarse' as const,
};
const wrapper = ({ children }: { children: React.ReactNode }) => (
<UserLocationProvider location={initialLocation}>
{children}
</UserLocationProvider>
);
const { result } = renderHook(() => useUserLocation(), { wrapper });
expect(result.current.location).toEqual(initialLocation);
});
it('should request precise location', async () => {
const mockGeolocation = {
getCurrentPosition: jest.fn(),
};
Object.defineProperty(global.navigator, 'geolocation', {
value: mockGeolocation,
configurable: true,
});
mockGeolocation.getCurrentPosition.mockImplementation((success) => {
success({
coords: {
latitude: 37.7749,
longitude: -122.4194,
accuracy: 10,
},
});
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<UserLocationProvider>
{children}
</UserLocationProvider>
);
const { result } = renderHook(() => useUserLocation(), { wrapper });
let newLocation: any;
await act(async () => {
newLocation = await result.current.requestPreciseLocation();
});
expect(newLocation).toEqual({
latitude: 37.7749,
longitude: -122.4194,
accuracy: 'precise',
});
expect(result.current.location).toEqual(newLocation);
});
it('should throw error when used outside provider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
expect(() => {
renderHook(() => useUserLocation());
}).toThrow('useUserLocation must be used within UserLocationProvider');
consoleSpy.mockRestore();
});
});Testing Async State Machines
Pattern: Testing State Transitions
import { renderHook, act } from '@testing-library/react';
import { useAsyncOperation } from './useAsyncOperation';
describe('useAsyncOperation', () => {
it('should transition through states correctly', async () => {
const mockOperation = jest.fn().mockResolvedValue({ data: 'success' });
const { result } = renderHook(() => useAsyncOperation(mockOperation));
// Initial state
expect(result.current.state).toEqual({ status: 'idle' });
// Execute operation
act(() => {
result.current.execute();
});
// Should be loading
expect(result.current.state).toEqual({ status: 'loading' });
// Wait for success
await waitFor(() => {
expect(result.current.state.status).toBe('success');
});
expect(result.current.state).toEqual({
status: 'success',
data: { data: 'success' },
});
});
it('should handle errors', async () => {
const mockError = new Error('Operation failed');
const mockOperation = jest.fn().mockRejectedValue(mockError);
const { result } = renderHook(() => useAsyncOperation(mockOperation));
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.state.status).toBe('error');
});
expect(result.current.state).toEqual({
status: 'error',
error: mockError,
});
});
it('should reset state', async () => {
const mockOperation = jest.fn().mockResolvedValue({ data: 'success' });
const { result } = renderHook(() => useAsyncOperation(mockOperation));
// Execute and wait for success
await act(async () => {
await result.current.execute();
});
expect(result.current.state.status).toBe('success');
// Reset
act(() => {
result.current.reset();
});
expect(result.current.state).toEqual({ status: 'idle' });
});
});Mocking SWR
Pattern: Custom SWR Provider for Tests
import { SWRConfig, Cache } from 'swr';
export function createTestSWRConfig(initialData: Map<string, any> = new Map()) {
const cache = new Map(initialData);
return {
value: {
provider: () => cache as Cache,
dedupingInterval: 0,
},
};
}
// Usage in tests
describe('Component with SWR', () => {
it('should render with cached data', () => {
const mockData = { id: '1', name: 'Test User' };
const config = createTestSWRConfig(
new Map([['/api/users/1', mockData]])
);
const { result } = renderHook(
() => useUserData('1'),
{
wrapper: ({ children }) => (
<SWRConfig {...config}>
{children}
</SWRConfig>
),
}
);
// Data available immediately from cache
expect(result.current.data).toEqual(mockData);
expect(result.current.isLoading).toBe(false);
});
});Testing Component Integration
Pattern: Testing Components with Custom Hooks
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchComponent } from './SearchComponent';
jest.mock('swr', () => ({
__esModule: true,
default: jest.fn(),
}));
import useSWR from 'swr';
describe('SearchComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('should debounce search input', async () => {
(useSWR as jest.Mock).mockReturnValue({
data: [],
isLoading: false,
error: null,
});
const user = userEvent.setup({ delay: null });
render(<SearchComponent />);
const input = screen.getByPlaceholderText('Search...');
// Type quickly
await user.type(input, 'test');
// Should not fetch yet
expect(useSWR).toHaveBeenCalledWith(null);
// Fast-forward debounce delay
act(() => {
jest.advanceTimersByTime(300);
});
// Now should fetch
await waitFor(() => {
expect(useSWR).toHaveBeenCalledWith(
expect.stringContaining('test')
);
});
});
it('should show loading state while debouncing', async () => {
(useSWR as jest.Mock).mockReturnValue({
data: undefined,
isLoading: false,
error: null,
});
const user = userEvent.setup({ delay: null });
render(<SearchComponent />);
const input = screen.getByPlaceholderText('Search...');
await user.type(input, 'test');
// Should show loading indicator
expect(screen.getByRole('status')).toBeInTheDocument();
// Complete debounce
act(() => {
jest.advanceTimersByTime(300);
});
// Loading indicator should persist if fetching
(useSWR as jest.Mock).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
});
});
});Testing Performance Optimizations
Pattern: Verifying Memo Behavior
import { render } from '@testing-library/react';
import { ExpensiveComponent } from './ExpensiveComponent';
describe('ExpensiveComponent memo behavior', () => {
it('should not re-render when props do not change', () => {
const renderSpy = jest.fn();
function TestComponent({ data }: { data: any }) {
renderSpy();
return <ExpensiveComponent data={data} />;
}
const { rerender } = render(<TestComponent data={{ id: 1 }} />);
expect(renderSpy).toHaveBeenCalledTimes(1);
// Re-render with same props reference
const sameData = { id: 1 };
rerender(<TestComponent data={sameData} />);
// Should re-render once for parent, memo should prevent child re-render
expect(renderSpy).toHaveBeenCalledTimes(2);
});
});Pattern: Testing useCallback Stability
import { renderHook } from '@testing-library/react';
describe('useCallback stability', () => {
it('should maintain callback reference', () => {
const { result, rerender } = renderHook(
({ count }) => {
const [localState, setLocalState] = useState(0);
const stableCallback = useCallback(() => {
console.log('callback');
}, []);
return { stableCallback, localState, setLocalState };
},
{ initialProps: { count: 0 } }
);
const firstCallback = result.current.stableCallback;
// Change unrelated prop
rerender({ count: 1 });
// Callback reference should be same
expect(result.current.stableCallback).toBe(firstCallback);
// Change local state
act(() => {
result.current.setLocalState(1);
});
// Callback reference should still be same
expect(result.current.stableCallback).toBe(firstCallback);
});
});Snapshot Testing for Hook Output
Pattern: Hook Output Snapshots
import { renderHook } from '@testing-library/react';
describe('useUserStats snapshots', () => {
it('should match snapshot for user stats', () => {
const mockUser = {
id: '1',
name: 'John Doe',
posts: [
{ id: '1', likes: 10 },
{ id: '2', likes: 20 },
],
};
const { result } = renderHook(() => useUserStats(mockUser));
expect(result.current).toMatchSnapshot();
});
});End-to-End Testing with Playwright
Pattern: Testing Complete User Flow
import { test, expect } from '@playwright/test';
test.describe('Search with debounce', () => {
test('should debounce search and show results', async ({ page }) => {
await page.goto('/search');
const searchInput = page.locator('input[placeholder="Search..."]');
const loadingIndicator = page.locator('[role="status"]');
// Type search query
await searchInput.fill('test query');
// Loading indicator should appear immediately
await expect(loadingIndicator).toBeVisible();
// Wait for debounce and results
await expect(page.locator('[data-testid="search-results"]')).toBeVisible();
// Should show results
await expect(page.locator('[data-testid="result-item"]')).toHaveCount(5);
});
});Related skills
FAQ
How does the SWR hook avoid unnecessary fetches?
It uses a null conditional key so SWR does not fetch until all conditions are met.
Why memoize context values?
To prevent re-renders in consumers when the provider re-renders.