React Hooks Best Practices: Patterns for Clean, Correct, and Performant Components
A practical guide to React Hooks best practices: state, effects, memoization, refs, custom hooks, concurrency, testing, and pitfalls—with examples.
Image used for representation purposes only.
Overview
React hooks let you manage state, side effects, refs, and performance without classes. Used well, they produce components that are predictable, testable, and fast. Used carelessly, they invite stale data, re-render storms, and subtle bugs. This guide distills practical patterns for writing clean, correct, and performant hook-based React code.
Core rules refresher (non-negotiable)
- Call hooks at the top level of React function components or custom hooks—never inside loops, conditions, or nested functions.
- Only call hooks from React components or other custom hooks.
- Keep concerns focused: a hook should do one thing well (subscribe to something, manage a form, coordinate an effect, etc.).
- Turn on the official ESLint rules: eslint-plugin-react-hooks (rules: react-hooks/rules-of-hooks and react-hooks/exhaustive-deps).
Choosing and shaping state
Pick the right primitive
- useState: local, simple, independent pieces of state.
- useReducer: complex updates, branching logic, or when multiple fields update together.
- useRef: mutable value that does not trigger re-renders (timers, DOM nodes, instance vars).
Keep state minimal and normalized
- Avoid duplicating or deriving values in state. Derive on render when cheap or memoize when expensive.
- Prefer multiple useState calls over a deeply nested object. It reduces accidental coupling.
// Prefer splitting state
const [title, setTitle] = useState("");
const [count, setCount] = useState(0);
// If object is necessary, use functional updates to avoid stale reads
setForm(prev => ({ ...prev, name: nextName }));
Prefer functional updates for dependent state
setCount(c => c + 1); // safe with concurrent rendering and batched updates
When to use useReducer
- Complex transitions or validation
- Logging/debugging via middleware-like reducers
- Co-locating state transitions in one place
type Action = { type: 'typed'; text: string } | { type: 'increment' };
type State = { text: string; count: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'typed':
return { ...state, text: action.text };
case 'increment':
return { ...state, count: state.count + 1 };
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, { text: '', count: 0 });
Effects: sync with the outside world, not logic dumping ground
Effects are for synchronizing with systems outside React (network, DOM, storage, subscriptions). Avoid putting pure computations or state derivations in useEffect—compute in render instead.
Make dependencies correct, then stabilize inputs
- Start by listing every variable used inside the effect in the dependency array (enable react-hooks/exhaustive-deps).
- If re-renders become excessive, stabilize references with useMemo/useCallback or restructure state.
useEffect(() => {
document.title = `Unread (${unreadCount})`;
}, [unreadCount]);
Clean up and prevent races
Always return a cleanup for subscriptions, timers, and DOM listeners. Abort in-flight fetches to avoid setting state after unmount or out-of-order updates.
useEffect(() => {
const ac = new AbortController();
let mounted = true;
(async () => {
const res = await fetch(`/api/items`, { signal: ac.signal });
if (!mounted) return;
// handle res
})().catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => {
mounted = false;
ac.abort();
};
}, []);
Avoid stale closures
- Prefer functional state updates (setX(prev => …)).
- For reading a value that should not cause re-render, store it in a ref and read ref.current inside the effect.
const latestQuery = useRef(query);
useEffect(() => { latestQuery.current = query; }, [query]);
useEffect vs useLayoutEffect
- useEffect: runs after paint—preferred in most cases.
- useLayoutEffect: runs before paint—reserve for DOM measurements or synchronizing layout. Avoid on the server (it no-ops but can warn).
Memoization: purposeful, not habitual
Memoization stabilizes identities and caches expensive computations, but it adds cognitive overhead. Apply it where it matters.
useMemo for expensive derivations
- Use when computation is heavy or dependency changes are infrequent.
- Do not use useMemo to paper over incorrect effects—fix dependencies instead.
const sorted = useMemo(() => heavySort(items), [items]);
useCallback for stable handlers passed to memoized children
const onSelect = useCallback((id: string) => {
setSelected(prev => (prev === id ? null : id));
}, []);
React.memo and context granularity
- Wrap pure presentational children with React.memo.
- For contexts, memoize the provided value to prevent spurious re-renders.
- Consider splitting contexts or using selector patterns to avoid whole-tree updates.
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
Refs, imperatives, and DOM work
- useRef to hold mutable, instance-like values without causing re-renders (timeouts, previous props, external APIs).
- Forward refs through components (forwardRef) when you need to expose imperative handles (focus, scroll, measure).
- useImperativeHandle to curate a minimal, stable surface.
const Input = React.forwardRef<HTMLInputElement, JSX.IntrinsicElements['input']>(
(props, ref) => {
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({ focus: () => innerRef.current?.focus() }), []);
return <input ref={innerRef} {...props} />;
}
);
Concurrency-friendly UX: transitions and deferred values
- useTransition keeps urgent updates (typing) responsive while deferring expensive UI work.
- useDeferredValue defers a value itself to let heavy consumers render later.
const [isPending, startTransition] = useTransition();
const onFilterChange = (q: string) => {
setQuery(q); // urgent
startTransition(() => setFilter(q)); // non-urgent
};
Data fetching: effects vs libraries
- You can fetch in useEffect with proper cleanup, but production apps benefit from data-libraries (caching, re-fetching, deduping, mutation semantics, and Suspense compatibility).
- For SSR/streaming, colocate data needs and lean on frameworks that integrate with Suspense and caching.
Custom hooks: design for reuse and clarity
- Name starts with use and describes capability: useLocalStorage, useOnlineStatus.
- Accept inputs as parameters; return a stable API (object or tuple). Document which values are stable vs change per render.
- Encapsulate effects, subscriptions, and cleanup inside the hook.
- Keep return shape predictable; prefer objects when values are optional or many.
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = typeof window !== 'undefined' ? localStorage.getItem(key) : null;
return raw != null ? (JSON.parse(raw) as T) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const; // stable tuple shape
}
Accessibility helpers with hooks
- useId for deterministic, collision-free IDs that match between server and client. Connect labels and inputs reliably.
- Manage focus with refs after conditional renders (e.g., focus first invalid field on submit) in useEffect or useLayoutEffect as needed.
- Announce async state changes (loading, errors) to screen readers via aria-live regions.
const id = useId();
<label htmlFor={`${id}-email`}>Email</label>
<input id={`${id}-email`} />
Testing hooks and components
- Test behavior, not internals: assert rendered output and interactions.
- For custom hooks, use a hook-testing utility that renders a host component; verify state transitions and effects.
- Prefer deterministic hooks (pure inputs -> outputs) and isolate side effects behind small, mockable boundaries.
Performance checklist
- State: minimal, non-duplicated, split by concern; useReducer when transitions are complex.
- Effects: correct dependencies, proper cleanup, avoid for pure computations.
- Memoization: apply where it changes render frequency or identity churn; avoid blanket memoization.
- Context: memoize values; split by usage; consider selectors.
- Lists: use stable keys; avoid creating new functions/objects in hot paths when unnecessary.
- Transitions: keep typing and gestures urgent; defer heavy renders.
Common anti-patterns (and fixes)
- Doing work in useEffect that can be done during render. Fix: compute directly or useMemo.
- Incorrect dependency arrays. Fix: enable exhaustive-deps and stabilize inputs.
- Storing derived state (e.g., filtered list) that can be computed. Fix: derive or memoize.
- useRef as hidden state. Fix: use state for UI-affecting data; refs for instance-only data.
- Passing unstable objects as props (e.g., style={{}}) to memoized children. Fix: memoize or lift constants.
Putting it together: a robust fetch hook
interface FetchState<T> {
data: T | null;
error: Error | null;
loading: boolean;
}
function useFetch<T>(url: string, opts?: RequestInit) {
const [state, setState] = useState<FetchState<T>>({ data: null, error: null, loading: false });
// Stabilize options to avoid unnecessary re-fetches
const stableOpts = useMemo(() => opts ?? {}, [opts]);
useEffect(() => {
const ac = new AbortController();
let active = true;
(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const res = await fetch(url, { ...stableOpts, signal: ac.signal });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const json = (await res.json()) as T;
if (!active) return;
setState({ data: json, error: null, loading: false });
} catch (e) {
if ((e as any).name === 'AbortError') return;
if (!active) return;
setState({ data: null, error: e as Error, loading: false });
}
})();
return () => {
active = false;
ac.abort();
};
}, [url, stableOpts]);
const refetch = useCallback(() => {
// trigger by nudging a key or re-calling with a different url/opts
// left to caller’s composition needs
}, []);
return { ...state, refetch } as const;
}
Tooling and guardrails
- Enable eslint-plugin-react-hooks and fix all warnings.
- TypeScript: type your hook inputs/outputs; prefer discriminated unions in reducers and explicit return types for custom hooks.
- Measure before you optimize: use React DevTools Profiler and flamegraphs to confirm wins.
Final thoughts
Hooks are powerful because they make data flow explicit: what state you have, how you derive it, what external systems you sync with, and when identities should remain stable. Follow the rules, keep dependencies honest, encapsulate concerns in focused custom hooks, and optimize surgically. Your components will be simpler to read, easier to test, and kinder to users’ devices.
Related Posts
Build a GraphQL API and React Client: An End‑to‑End Tutorial
Learn GraphQL with React and Apollo Client by building a full stack app with queries, mutations, caching, and pagination—step by step.
React AI Chatbot Tutorial: Build a Streaming Chat UI with OpenAI and Node.js
Build a streaming React AI chatbot with a secure Node proxy using OpenAI’s Responses API. Code, SSE streaming, model tips, and production guidance.
OpenAI API with React: A 2026 Guide to Chat, Tools, and Realtime Voice
A practical 2026 guide to building React apps on the OpenAI API: secure setup, typed streaming, tools/function calling, and live voice with Realtime.