eslint-plugin-react-hooks — it catches most violations
automatically.Reason: React relies on the order of hook calls to correctly associate each hook with its state / effect / ref memory between renders.
Because the number and order of hook calls must be exactly the same on every render.
If a condition / loop changes between renders, some hooks might be skipped → React loses track of which state belongs to which hook → bugs, crashes or wrong values.
Solution: move conditional logic inside the hook:
useEffect(() => {
if (!isOpen) return;
// effect logic
}, [isOpen]);
The function passed to useState runs only once (on mount) → useful
for expensive calculations or reading from localStorage.
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('user');
return saved ? JSON.parse(saved) : null;
});
useEffect(fn, []) → componentDidMountuseEffect(fn, [dep1, dep2]) → componentDidUpdate (only when deps change)return () => cleanup() → componentWillUnmountuseEffect(fn) (no array) → componentDidMount + componentDidUpdatereturn () => clearInterval(timerId);return () => window.removeEventListener('resize', handler);const controller = new AbortController(); ... return () => controller.abort();
return () => subscription.unsubscribe(); (RxJS, Firebase, etc.)Effect runs after every render (including after setState inside it) → very often causes infinite render loops.
Only safe when you intentionally want to run code on every render (rare).
| Hook | Timing | Blocking | Use when |
|---|---|---|---|
| useEffect | after paint | no | data fetching, timers, most side effects |
| useLayoutEffect | before paint | yes | DOM measurements, prevent visual flicker, scroll adjustments |
Rule: default = useEffect. Use useLayoutEffect only if you see layout shift / flicker.
Skip expensive calculations on re-renders when dependencies have not changed.
const sortedUsers = useMemo(() => {
return [...users].sort((a,b) => a.name.localeCompare(b.name));
}, [users]);
React.memo child componentuseEffect,
useMemo)| useMemo | useCallback | |
|---|---|---|
| Memoizes | any value | function only |
| Return type | whatever factory returns | function |
| Common use | expensive derived data, objects, arrays | stable callbacks for children / deps |
Shallow comparison (Object.is) of all props.
Objects / arrays / functions are compared by reference → new object = re-render even if content is same.
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
const prev = usePrevious(count);
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
Lazy initialization — runs only once, even if component re-renders.
const [state, dispatch] = useReducer(reducer, initialArg, (arg) => expensiveSetup(arg));
The current context value from the nearest <Context.Provider> above in the
tree.
If no provider → returns the default value passed to createContext(defaultValue).
Allows a component to expose custom imperative methods to its parent via ref (instead of exposing the whole DOM node or instance).
Common: focus(), scrollToBottom(), play(), etc.
Marks state updates as non-urgent (transitions).
Allows React to keep showing old UI while preparing new content in background.
const [isPending, startTransition] = useTransition();
startTransition(() => setTab('profile'));
| Hook | Defers | Typical usage |
|---|---|---|
| useTransition | state update | tab switches, filtering large lists, navigation |
| useDeferredValue | value | search input → expensive filtered list |
Generates unique IDs that are stable across server and client → prevents hydration mismatch warnings.
const id = useId();
<label htmlFor={id}>Email</label>
<input id={id} />
Low-level hook to safely subscribe to external mutable stores (browser APIs, third-party state) in concurrent mode without tearing.
Used internally by:
Callback / effect captures old value because function was created in previous render.
Fixes:
useRef for mutable latest valueuseCallback + ref pattern for callbacksfunction useIsMounted() {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => { isMounted.current = false; };
}, []);
return isMounted;
}
// usage
const isMounted = useIsMounted();
useEffect(() => {
fetchData().then(data => {
if (isMounted.current) setData(data);
});
}, []);
Use @testing-library/react-hooks (now part of @testing-library/react)
import { renderHook, act } from '@testing-library/react';
test('counter increments', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
Simplifies optimistic UI updates – shows final state immediately, auto-reverts on error.
const [optimisticLikes, addOptimisticLike] = useOptimistic(likes);
async function handleLike() {
addOptimisticLike(likes + 1);
await api.like();
}
{}, [])New object reference created every render → effect thinks deps changed → runs every time.
Fix: use useMemo or move object creation inside effect.
const filters = useMemo(() => ({
status: 'active',
sort: 'desc'
}), []); // empty deps = created once
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
import produce from 'immer';
function reducer(state, action) {
return produce(state, draft => {
switch (action.type) {
case 'updateName':
draft.user.name = action.payload;
break;
// ...
}
});
}
Opt-in compiler (React 19+) that automatically memoizes components, hooks and values → reduces
need for manual useMemo / useCallback / React.memo.
Still experimental / opt-in in most codebases as of early 2026.
Replaces manual pending/error/state management for form actions.
const [state, formAction, isPending] = useActionState(async (prev, formData) => {
// server action logic
return { success: true };
}, { success: false });
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => { if (!controller.signal.aborted) setError(err); });
return () => controller.abort();
}, [url]);
renderHookfunction useAppContext() {
const theme = useContext(ThemeContext);
const user = useContext(UserContext);
const lang = useContext(LanguageContext);
return { theme, user, lang };
}
Allows reading promises / context in render (with Suspense).
const data = use(fetchDataPromise);
Works like await but in render phase → triggers Suspense fallback.
function useClickOutside(ref, handler) {
useEffect(() => {
const listener = e => {
if (!ref.current || ref.current.contains(e.target)) return;
handler(e);
};
document.addEventListener('mousedown', listener);
return () => document.removeEventListener('mousedown', listener);
}, [ref, handler]);
}
| Goal | Recommended | Reason |
|---|---|---|
| Reduce expensive renders on typing | useDeferredValue | React-aware, interruptible, no fixed delay |
| Reduce API calls on typing | debounce | Controls network timing |
use: useWindowSize, useAuth, useFetchsrc/hooks/ folderuseDebounce.js, useLocalStorage.jsWhen the setter is used inside useEffect and the effect depends on the setter →
prevents unnecessary effect runs.
But in React 18+ most cases are unnecessary because setters are stable.
Nothing special — React ignores it.
Use async function inside effect + .then / await if
needed.
Never return promise directly from useEffect.
Centralizes step navigation, validation, data in one reducer → easier to reason about flow.
Actions: nextStep, prevStep, updateField,
validate, submit
// BAD - runs every render
useEffect(() => {
const handler = () => console.log(count);
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, [count, handler]); // handler changes every render
Fix: wrap in useCallback or move inside effect.