Checked against React 19.2 on 2026-08-20.
Most React bugs that survive code review are effect bugs. Not useState bugs, not memoization bugs. Somebody synchronised two pieces of state through useEffect, and now there is a render where they disagree, or a fetch that races, or a dependency array that lies.
So this guide is organised around that. The hooks themselves are covered because you need them, but the part worth your attention is the last third: what useEffect is actually for, the three things people use it for that it is wrong for, and what to reach for instead. React's own documentation has a page titled "You Might Not Need an Effect", which tells you how common this is.
Hooks are the foundation under a Next.js client component, and they pair with TypeScript well enough that most of the annotations write themselves.
The Two Rules
Everything else is detail. These two are load-bearing:
- Call hooks at the top level only. Never in a loop, condition or nested function. React matches state to hooks by call order, and the order has to be identical on every render.
- Call hooks only from React functions. Function components and other hooks. Not from event handlers, class methods or plain utility functions.
eslint-plugin-react-hooks enforces both. Install it and do not disable it:
npm install -D eslint-plugin-react-hooksbashThe call-order rule is the reason a conditional hook does not merely fail, it corrupts. React hands hook number three's state to whatever hook is third this render, so a component that skips one hook silently reads another hook's value.
useState
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}tsxUse the functional form when the next state depends on the last
setCount(count + 1); // reads whatever count was when this closure was made
setCount((prev) => prev + 1); // always the latesttsxThe first form is the stale closure in its smallest possible form. It is correct until the update is batched, or fires from a timer, or fires twice in the same tick, and then it silently drops one.
Lazy initialisation
Pass a function when computing the initial value is expensive. The argument form evaluates on every render and throws the result away:
// Reads localStorage on every render
const [settings, setSettings] = useState(JSON.parse(localStorage.getItem("settings") || "{}"));
// Reads it once
const [settings, setSettings] = useState(() =>
JSON.parse(localStorage.getItem("settings") || "{}")
);tsxuseReducer when the transitions matter
When several fields move together, a reducer names the transitions instead of scattering them across setters:
interface FormState {
name: string;
email: string;
isSubmitting: boolean;
error: string | null;
}
type FormAction =
| { type: "SET_FIELD"; field: "name" | "email"; value: string }
| { type: "SUBMIT_START" }
| { type: "SUBMIT_SUCCESS" }
| { type: "SUBMIT_ERROR"; error: string };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return { ...state, [action.field]: action.value };
case "SUBMIT_START":
return { ...state, isSubmitting: true, error: null };
case "SUBMIT_SUCCESS":
return { ...state, isSubmitting: false };
case "SUBMIT_ERROR":
return { ...state, isSubmitting: false, error: action.error };
}
}tsxA discriminated union for the action type gives you exhaustiveness checking, so removing a case from the switch becomes a compile error rather than a state that quietly does nothing.
useEffect Is for Synchronising With Things Outside React
That is the whole job description. A subscription, a browser API, a timer, a third-party widget, a network connection. If nothing outside React is involved, an effect is probably the wrong tool.
The dependency array
useEffect(() => { /* ... */ }); // after every render
useEffect(() => { /* ... */ }, []); // once, after mount
useEffect(() => { /* ... */ }, [userId]); // when userId changestsxEvery value from component scope that the effect reads belongs in the array. The exhaustive-deps lint rule checks this. When it complains, the fix is almost never to silence it: an effect that reads a value it did not declare is an effect that will one day run with a version of that value from three renders ago.
Cleanup
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const response = await fetch(`/api/users/${userId}`, { signal: controller.signal });
setUser(await response.json());
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err);
}
}
loadData();
return () => controller.abort();
}, [userId]);tsxReact runs the cleanup before re-running the effect and again on unmount. Without the abort, changing userId twice quickly leaves two requests in flight and the slower one wins, so the screen shows the user you navigated away from.
The three things people use effects for that are not synchronisation
Deriving state from props or other state. Compute it while rendering.
// Anti-pattern: a render where the two disagree
const [filteredItems, setFilteredItems] = useState(items);
useEffect(() => {
setFilteredItems(items.filter((item) => item.active));
}, [items]);
// Correct
const filteredItems = items.filter((item) => item.active);
// Only if the computation is genuinely expensive
const filteredItems = useMemo(() => items.filter((i) => i.active), [items]);tsxResponding to a user event. Put it in the handler. An effect that watches a flag set by a click is a click handler with extra steps and one extra render.
Reading an external store. This one has its own hook, and it is the section below.
useSyncExternalStore, or How This Site Filters Posts
Here is a concrete case from this blog's own code, because it is the clearest example we have of the effect-shaped mistake and its fix.
The post list filters by topic, and the filter state lives in the URL query string so a filtered list can be linked. The first version did the obvious thing: useState for the filters, one effect to read window.location.search on mount, another to write it back on change.
Two bugs, both structural rather than sloppy:
- The write effect ran before the read effect's
setStatehad landed, so on first paint it wrote an empty query string over the real one. The params came back a render later. The user saw the URL get stripped and restored. - Nothing listened for
popstate, so the browser back button changed history without changing the list.
Both disappear once you stop treating the URL as component state. The URL is an external store: something outside React that changes on its own and that React needs to read consistently.
const URL_STATE_EVENT = 'url-filter-state';
function subscribe(onStoreChange: () => void): () => void {
// popstate covers back/forward; the custom event covers our own writes,
// which do not fire popstate.
window.addEventListener('popstate', onStoreChange);
window.addEventListener(URL_STATE_EVENT, onStoreChange);
return () => {
window.removeEventListener('popstate', onStoreChange);
window.removeEventListener(URL_STATE_EVENT, onStoreChange);
};
}
function getSnapshot(): string {
return window.location.search;
}
function getServerSnapshot(): string {
return '';
}
export function useUrlFilterState(): UrlFilterState {
const search = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const params = useMemo(() => new URLSearchParams(search), [search]);
// ...
}tsxThe third argument, getServerSnapshot, is doing real work here. The server has no window, and more importantly the post list has to appear in the prerendered HTML for crawlers. So the server snapshot is deliberately '': the server renders the list unfiltered, and the client narrows it after hydration. That difference between server and client is not a bug being papered over, it is the thing this hook exists to model.
The same shape covers matchMedia:
function usePrefersReducedMotion(): boolean {
return useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotion,
() => false // server default
);
}tsxReading a media query into state from an effect renders one frame with the wrong answer, and trips the set-state-in-effect lint rule while it does. useSyncExternalStore reads it during render instead.
Rule of thumb: if the value is owned by something that is not React and can change without React asking, it is an external store, not state plus an effect.
useRef
A mutable box that survives renders and does not cause them.
function SearchInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Search..." />;
}tsxThe other use is a value the render output does not depend on: a timer id, a previous value, the latest callback.
function useInterval(callback: () => void, delay: number) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}tsxThe indirection keeps the interval calling the current callback without tearing down and rebuilding the timer whenever the parent re-renders.
useMemo and useCallback
useMemo caches a computed value; useCallback caches a function identity. Both exist for two reasons only: an expensive computation, or a value that another hook or a memoised child compares by reference.
const filteredProducts = useMemo(
() => products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
[products, query]
);
const handleNameChange = useCallback((name: string) => setName(name), []);tsxWrapping every function in useCallback is not free and not neutral. It adds a dependency array to maintain, a closure to keep alive, and a comparison on every render, in exchange for skipping a re-render that was usually cheap. Reach for it when profiling says so, or when the value is a dependency of something else.
Custom Hooks
A custom hook is a function whose name starts with use and that calls other hooks. That is the entire specification. The point is reusing stateful logic, not reusing state: two components calling useFetch get two independent pieces of state.
useFetch
interface UseFetchResult<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((json) => setData(json as T))
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setIsLoading(false));
return () => controller.abort();
}, [url]);
return { data, error, isLoading };
}tsxWorth saying plainly: this is a teaching example, not a data layer. It has no cache, no deduplication, no retry, and it sets state after an aborted request in the finally. Once an app has more than a handful of these, use a library that has solved the cache invalidation problem rather than growing this one.
useLocalStorage
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const next = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(next));
return next;
});
},
[key]
);
return [storedValue, setValue] as const;
}tsxThe typeof window === "undefined" guard is what keeps this from throwing during the server render pass in Next.js. The try/catch is not decoration either: localStorage throws in Safari private browsing and when the origin's quota is full, and a throw inside a useState initialiser takes the whole component down.
Note that this hook does not react to changes made in another tab. If that matters, it is another external store, and it wants useSyncExternalStore over the storage event.
useDebounce
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}tsxThis one is a legitimate effect: a timer is an external system, and the cleanup cancels it.
Hooks and TypeScript
const [count, setCount] = useState(0); // number, inferred
const [user, setUser] = useState<User | null>(null); // annotate: null alone is not enough
const [items, setItems] = useState<string[]>([]); // annotate: never[] otherwise
const inputRef = useRef<HTMLInputElement>(null); // DOM ref
const timerRef = useRef<number | null>(null); // mutable reftsxThe rule is that inference works when the initial value is representative of every value the state will hold, and fails when it is not. null and [] are the two cases where it is not.
What Hooks Cost
Hooks are better than the lifecycle methods they replaced. They are not free.
- The dependency array is a manual proof obligation. The lint rule checks that you listed what you read. Nothing checks that the values are referentially stable, so an object literal in the array re-runs the effect forever. That failure mode did not exist with
componentDidUpdate. - Stale closures are the new
thisbug. Every hook captures the render it was created in.useState's functional updater,useReffor latest values, and theexhaustive-depsrule all exist to manage a hazard the model creates. useEffectis an escape hatch that reads like an API. Nothing about the name suggests "only for synchronising with external systems", so it becomes the default place to put anything that should happen after something else. React's own docs push back on this because the community learned it the hard way.- Order-dependence is invisible at the call site. An early
returnabove a hook is a legal-looking edit that breaks the component. Only the lint rule catches it. - Memoisation is easy to cargo-cult.
useMemoanduseCallbackeverywhere costs allocation and comparison on every render and usually saves nothing measurable.
None of that argues for class components. It argues for reading the rules once, keeping the lint rule on, and being suspicious of every effect you write.
FAQ
What are React hooks?
Functions that let a function component use state, side effects, context, refs and memoisation. They replaced the class-component patterns of this.state, lifecycle methods and higher-order component wrappers with primitives that compose, so related logic lives together instead of being split across componentDidMount and componentWillUnmount.
When should I not use useEffect?
When nothing outside React is involved. Do not use an effect to derive state from props or other state (compute it during render, or useMemo if it is expensive), to respond to a user event (use the handler), or to read a value from an external store (use useSyncExternalStore). Effects are for synchronising with systems React does not own: subscriptions, timers, network connections, browser APIs.
What is the difference between useState and useReducer?
useState suits independent values: a toggle, a counter, an input. useReducer suits state whose fields move together through a set of named transitions, because the transitions end up in one place instead of spread across call sites. If your setter calls are growing conditionals, that is the signal.
Why does my useEffect run in an infinite loop?
Because it updates something in its own dependency array, and the most common form of that is a non-primitive dependency created during render. An object or array literal is a new reference every render, so the effect re-runs, sets state, re-renders, and repeats. Stabilise it with useMemo, depend on primitive fields instead of the object, or move the work out of the effect.
Can I call a hook inside an if statement?
No. Hooks must be called in the same order on every render, because React associates state with hooks positionally. A conditional hook does not throw a clear error; it hands your component another hook's state.
When should I build a custom hook?
When the same stateful logic appears in two or more components. Data fetching, forms, storage sync, debouncing, media queries and intersection observers are the usual candidates. Keep each one to a single job with a name that describes it, and remember that callers get independent state, not shared state.
Do hooks work in Next.js Server Components?
No. Server Components render once on the server and have no client lifecycle, so they cannot hold state or run effects. Add the "use client" directive to the file to make a client component. Prefer fetching data in Server Components or route handlers and keep hooks for interactive behaviour.
Sources
Checked 2026-08-20.
- Rules of Hooks - the top-level and React-functions-only rules, and why call order matters.
- You Might Not Need an Effect - deriving state during render, handling events in handlers, and effects as an escape hatch.
- useEffect reference - dependency arrays, cleanup timing, and effects as synchronisation with external systems.
- useMemo and useCallback - when memoisation is worth it and when it is not.
- useRef - refs as values that persist without triggering renders.
- useSyncExternalStore - subscribing to a store outside React, and the
getServerSnapshotargument used during server rendering and hydration. - useReducer - reducer shape and when it beats multiple state variables.