Advanced Hook Patterns
Deep dive into useState vs useReducer, custom hooks, and hook composition
Core Patterns
- When to Read This
- useState vs useReducer
- Custom Hooks
- useRef Patterns
When to Read This
- Deciding between useState and useReducer for complex state
- Creating reusable custom hooks
- Managing hook dependencies and closures
- Working with refs for mutable values
- Need stable references across renders
useState vs useReducer
Decision Matrix
| Scenario | Use | Reason |
|---|---|---|
| Single primitive value | useState | Simple, direct |
| 2-3 related values that update together | useState with object | Easier to read |
| 4+ related values with complex updates | useReducer | Centralized logic |
| State depends on previous state | useReducer | Prevents stale closures |
| Multiple state update functions | useReducer | Predictable transitions |
| Need to test state logic separately | useReducer | Testable reducer |
useState Patterns
✅ Simple State
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
const [isOpen, setIsOpen] = useState(false);
✅ Functional Updates (Prevent Stale Closures)
// ❌ WRONG: Stale closure in setTimeout
const [count, setCount] = useState(0);
setTimeout(() => {
setCount(count + 1); // Uses stale count
}, 1000);
// ✅ CORRECT: Functional update
setTimeout(() => {
setCount((prev) => prev + 1); // Always current
}, 1000);
✅ Lazy Initialization
// ❌ WRONG: Expensive computation on every render
const [data, setData] = useState(expensiveComputation());
// ✅ CORRECT: Lazy initializer (runs once)
const [data, setData] = useState(() => expensiveComputation());
✅ Object State with Spreading
const [form, setForm] = useState({ name: "", email: "" });
// Update single field
setForm((prev) => ({ ...prev, name: "John" }));
// Update multiple fields
setForm((prev) => ({ ...prev, name: "John", email: "john@example.com" }));
useReducer Patterns
✅ Complex State Logic
interface State {
user: User | null;
loading: boolean;
error: string | null;
}
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: User }
| { type: "FETCH_ERROR"; payload: string }
| { type: "LOGOUT" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, loading: true, error: null };
case "FETCH_SUCCESS":
return { user: action.payload, loading: false, error: null };
case "FETCH_ERROR":
return { ...state, loading: false, error: action.payload };
case "LOGOUT":
return { user: null, loading: false, error: null };
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, {
user: null,
loading: false,
error: null,
});
dispatch({ type: "FETCH_START" });
dispatch({ type: "FETCH_SUCCESS", payload: userData });
✅ Reducer with Immer (Immutable Updates)
import { useReducer } from "react";
import { produce } from "immer";
const reducer = produce((draft: State, action: Action) => {
switch (action.type) {
case "ADD_TODO":
draft.todos.push(action.payload);
break;
case "TOGGLE_TODO":
const todo = draft.todos.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
break;
}
});
Custom Hooks
✅ Extract Reusable Logic
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage("theme", "dark");
✅ Custom Hook with Cleanup
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// Update ref when callback changes
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
useInterval(() => {
console.log("Tick");
}, 1000);
✅ Custom Hook with Multiple Values
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
const modal = useToggle();
modal.setTrue(); // Open
modal.toggle(); // Close
✅ Custom Hook Composition
function useAuth() {
const [user, setUser] = useLocalStorage<User | null>("user", null);
const [loading, setLoading] = useState(false);
const login = useCallback(
async (credentials: Credentials) => {
setLoading(true);
try {
const user = await api.login(credentials);
setUser(user);
} finally {
setLoading(false);
}
},
[setUser],
);
const logout = useCallback(() => {
setUser(null);
}, [setUser]);
return { user, loading, login, logout };
}
useRef Patterns
✅ DOM References
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
const focus = () => {
inputRef.current?.focus();
};
return <input ref={inputRef} />;
}
✅ Mutable Values (No Re-render)
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef<number>();
const start = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
};
const stop = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
return (
<div>
{count}
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
✅ Previous Value Tracking
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
// prevCount is always the previous value
useImperativeHandle
✅ Exposing Custom Ref API
import { forwardRef, useImperativeHandle, useRef } from 'react';
interface InputHandle {
focus: () => void;
clear: () => void;
}
const CustomInput = forwardRef<InputHandle, { placeholder?: string }>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current?.focus();
},
clear: () => {
if (inputRef.current) {
inputRef.current.value = '';
}
},
}));
return <input ref={inputRef} {...props} />;
});
function Parent() {
const inputRef = useRef<InputHandle>(null);
return (
<div>
<CustomInput ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
<button onClick={() => inputRef.current?.clear()}>Clear</button>
</div>
);
}
Hook Dependencies & Closures
⚠️ Stale Closures Problem
// ❌ WRONG: Stale closure
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs 0 (stale)
setCount(count + 1); // Always adds to 0
}, 1000);
return () => clearInterval(id);
}, []); // Missing count dependency
return <div>{count}</div>;
}
// ✅ CORRECT: Functional update
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // Always current
}, 1000);
return () => clearInterval(id);
}, []); // No dependencies needed
✅ Exhaustive Dependencies
function SearchResults({ query, filters }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchResults(query, filters).then(setResults);
}, [query, filters]); // Both dependencies
return <div>{/* ... */}</div>;
}
✅ Stable References with useCallback
function Parent() {
const [count, setCount] = useState(0);
// ❌ WRONG: New function on every render
const handleClick = () => {
setCount(count + 1);
};
// ✅ CORRECT: Stable function reference
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // No dependencies with functional update
return <MemoizedChild onClick={handleClick} />;
}
Advanced Patterns
✅ State Machine with useReducer
type State = "idle" | "loading" | "success" | "error";
type Action =
| { type: "FETCH" }
| { type: "SUCCESS" }
| { type: "ERROR" }
| { type: "RESET" };
function reducer(state: State, action: Action): State {
switch (state) {
case "idle":
return action.type === "FETCH" ? "loading" : state;
case "loading":
return action.type === "SUCCESS"
? "success"
: action.type === "ERROR"
? "error"
: state;
case "success":
case "error":
return action.type === "RESET" ? "idle" : state;
default:
return state;
}
}
✅ Optimistic Updates
function useMutation<T>(mutationFn: (data: T) => Promise<void>) {
const [optimisticData, setOptimisticData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const mutate = useCallback(
async (data: T) => {
setOptimisticData(data); // Show immediately
setError(null);
try {
await mutationFn(data);
} catch (err) {
setError(err as Error);
setOptimisticData(null); // Rollback on error
}
},
[mutationFn],
);
return { optimisticData, error, mutate };
}
React 18+ Hooks
useId — SSR-Safe Unique IDs
Generate unique IDs stable across server and client rendering.
// ✅ CORRECT: useId for form label association
function FormField({ label }: { label: string }) {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} />
</div>
);
}
// ❌ WRONG: Math.random() or counter (SSR mismatch)
let counter = 0;
function FormField({ label }: { label: string }) {
const id = `field-${counter++}`; // Hydration mismatch!
return <input id={id} />;
}
useTransition — Non-Urgent State Updates
Mark state updates as non-urgent to keep UI responsive during heavy renders.
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value); // Urgent: update input immediately
startTransition(() => {
setResults(filterItems(e.target.value)); // Non-urgent: can be interrupted
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultList items={results} />
</div>
);
}
useDeferredValue — Deferred Rendering
Defer re-rendering of a value to keep UI responsive.
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// Expensive computation uses deferred (stale) value
const results = useMemo(() => filterLargeDataset(deferredQuery), [deferredQuery]);
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultList items={results} />
</div>
);
}
useTransition vs useDeferredValue:
useTransition: You control when to start the transition (wrapsetState)useDeferredValue: React defers the value automatically (wrap the value)
useSyncExternalStore — External Store Subscription
Subscribe to external stores (non-React state) safely.
function useWindowWidth(): number {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth, // Client snapshot
() => 1024, // Server snapshot (SSR fallback)
);
}
function ResponsiveLayout() {
const width = useWindowWidth();
return width > 768 ? <DesktopLayout /> : <MobileLayout />;
}