React hooks transformed how state and side effects are encapsulated — but the real power is in custom hooks that extract domain logic from components. A well-designed custom hook is reusable across components, testable in isolation, and composable with other hooks. Claude Code writes custom hooks that follow the rules correctly: stable callback references, cleanup in useEffect, predictable rendering behavior, and the TypeScript generics that make hooks reusable without losing type safety.
CLAUDE.md for React Hooks Projects
## React Hooks Standards
- Custom hooks: prefix with use, single responsibility, documented with JSDoc
- useEffect: every dependency must be in the array — no eslint-disable comments
- Stable references: useCallback for callbacks passed to children; useMemo for expensive computations only
- State shape: useReducer when state has >2 related fields or complex transitions
- Context: never put frequently-changing data in context — it re-renders all consumers
- Testing: test custom hooks with @testing-library/react renderHook
- Concurrent mode safe: no observing external mutable variables directly — use useSyncExternalStore
useReducer for Complex State
// hooks/useOrderForm.ts — complex form state with useReducer
import { useReducer, useCallback } from 'react';
type OrderItem = { productId: string; quantity: number; priceCents: number };
type OrderFormState = {
items: OrderItem[];
customerId: string;
notes: string;
isSubmitting: boolean;
submitError: string | null;
};
type OrderFormAction =
| { type: 'ADD_ITEM'; item: OrderItem }
| { type: 'REMOVE_ITEM'; productId: string }
| { type: 'UPDATE_QUANTITY'; productId: string; quantity: number }
| { type: 'SET_CUSTOMER'; customerId: string }
| { type: 'SET_NOTES'; notes: string }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; error: string };
function orderFormReducer(state: OrderFormState, action: OrderFormAction): OrderFormState {
switch (action.type) {
case 'ADD_ITEM':
const existing = state.items.find(i => i.productId === action.item.productId);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.productId === action.item.productId
? { ...i, quantity: i.quantity + action.item.quantity }
: i
),
};
}
return { ...state, items: [...state.items, action.item] };
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter(i => i.productId !== action.productId) };
case 'UPDATE_QUANTITY':
if (action.quantity <= 0) {
return { ...state, items: state.items.filter(i => i.productId !== action.productId) };
}
return {
...state,
items: state.items.map(i =>
i.productId === action.productId ? { ...i, quantity: action.quantity } : i
),
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true, submitError: null };
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false, items: [], customerId: '', notes: '' };
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, submitError: action.error };
default:
return state;
}
}
export function useOrderForm() {
const [state, dispatch] = useReducer(orderFormReducer, {
items: [],
customerId: '',
notes: '',
isSubmitting: false,
submitError: null,
});
// Stable callbacks — memo'd so they don't re-render children
const addItem = useCallback((item: OrderItem) =>
dispatch({ type: 'ADD_ITEM', item }), []);
const removeItem = useCallback((productId: string) =>
dispatch({ type: 'REMOVE_ITEM', productId }), []);
const submit = useCallback(async () => {
dispatch({ type: 'SUBMIT_START' });
try {
await api.createOrder({ items: state.items, customerId: state.customerId });
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', error: err.message });
}
}, [state.items, state.customerId]);
const totalCents = state.items.reduce((sum, i) => sum + i.quantity * i.priceCents, 0);
return { ...state, totalCents, addItem, removeItem, submit };
}
Custom Async Hook
// hooks/useAsync.ts — generic async operation with loading/error/data states
import { useState, useEffect, useCallback, useRef } from 'react';
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
export function useAsync<T>(
asyncFn: () => Promise<T>,
deps: React.DependencyList,
): AsyncState<T> & { refetch: () => void } {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const execute = useCallback(async () => {
setState({ status: 'loading' });
try {
const data = await asyncFn();
if (mountedRef.current) {
setState({ status: 'success', data });
}
} catch (err) {
if (mountedRef.current) {
setState({ status: 'error', error: err instanceof Error ? err : new Error(String(err)) });
}
}
}, deps);
useEffect(() => {
execute();
}, [execute]);
return { ...state, refetch: execute };
}
// Usage:
// const { status, data, error, refetch } = useAsync(() => api.getOrders(userId), [userId]);
useSyncExternalStore for External State
// hooks/useThemeStore.ts — subscribe to external store safely (Concurrent Mode compatible)
import { useSyncExternalStore } from 'react';
// External store (could be Zustand, Redux, or any observable)
type ThemeStore = { theme: 'light' | 'dark'; accentColor: string };
class ThemeStoreImpl {
private state: ThemeStore = { theme: 'light', accentColor: '#2563eb' };
private listeners = new Set<() => void>();
getSnapshot = (): ThemeStore => this.state;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
setTheme(theme: ThemeStore['theme']) {
this.state = { ...this.state, theme };
this.listeners.forEach(l => l());
}
}
export const themeStore = new ThemeStoreImpl();
// Hook: re-renders only when store changes
export function useTheme() {
return useSyncExternalStore(
themeStore.subscribe,
themeStore.getSnapshot,
// Server snapshot (for SSR):
() => ({ theme: 'light' as const, accentColor: '#2563eb' }),
);
}
Hook Composition Pattern
// hooks/useOrderManagement.ts — compose smaller hooks into domain hook
import { useCallback } from 'react';
import { useAsync } from './useAsync';
import { useLocalStorage } from './useLocalStorage';
// Small single-purpose hooks compose into domain hooks
function useLocalStorage<T>(key: string, initial: T) {
const [value, setRaw] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
} catch {
return initial;
}
});
const setValue = useCallback((newValue: T | ((prev: T) => T)) => {
setRaw(prev => {
const next = typeof newValue === 'function' ? (newValue as Function)(prev) : newValue;
localStorage.setItem(key, JSON.stringify(next));
return next;
});
}, [key]);
return [value, setValue] as const;
}
export function useOrderManagement(userId: string) {
const [draftOrderId, setDraftOrderId] = useLocalStorage<string | null>(
`draft_order_${userId}`, null
);
const ordersState = useAsync(
() => api.getOrders(userId),
[userId]
);
const createOrder = useCallback(async (items: OrderItem[]) => {
const order = await api.createOrder({ userId, items });
setDraftOrderId(null); // Clear draft after creation
return order;
}, [userId, setDraftOrderId]);
return {
orders: ordersState,
draftOrderId,
setDraftOrderId,
createOrder,
};
}
For the React state management libraries (Zustand, Jotai) that replace complex useReducer patterns in large apps, the React frontend guide covers library selection criteria. For the React Query data-fetching hooks that replace useAsync with caching and background refetch, the React Query guide covers the full TanStack Query API. The Claude Skills 360 bundle includes React hooks skill sets covering custom hook design, useReducer patterns, context composition, and concurrent mode compliance. Start with the free tier to try custom hook generation.