React in Depth
Hooks in Depth: useRef, useMemo, useCallback, useReducer and Custom Hooks
Each hook with one small reason to use it, the rules of hooks and why they exist, and how to write a custom hook.
On this page
- Words used in this lesson
- How hooks work, with a 15-line engine
- useRef: a box that React does not watch
- useMemo: remember a calculation
- useCallback: remember a function
- useReducer: one place for all the ways state can change
- Custom hooks: share logic, not state
- useSyncExternalStore: reading things that live outside React
- The use API (React 19)
- useId, in one paragraph
- Which hook for which job
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: a hook is a function, whose name starts with use, that lets a component plug into a React feature: memory, effects, refs, context, caching. Hooks work because React remembers them by the order they are called, so you must call them in the same order on every render. A custom hook is just your own function that calls other hooks, to share logic and not state.
You already know useState and useEffect. This lesson adds the rest of the everyday set: useRef, useMemo, useCallback, useReducer, and the new use from React 19. For each I give one honest reason to use it, and one test I ran. Then comes the part many people skip: why the rules of hooks exist. I wrote a tiny hook engine in 15 lines to show it, and I broke the rules in real React to show you the real error message.
The previous lesson was Effects and the Lifecycle. The next one, Forms, Events and Controlled Inputs, puts hooks to work in forms.
What I ran / checked. Node 22.22.0, React 19.3.0, react-dom 19.3.0, jsdom 29.1.1, esbuild 0.28.2, development build. Every output below came from components mounted with createRoot in jsdom and wrapped in act(), except the mini hooks engine, which is a plain script. I read these pages from the reactjs/react.dev repository (raw markdown): Rules of Hooks, Referencing Values with Refs, Extracting State Logic into a Reducer, Reusing Logic with Custom Hooks, and the reference pages for useRef, useMemo, useCallback, useReducer, useId and use. I did not run the React Compiler in this lesson (lesson 7 talks about it, and says what I did and did not run). Code and explanations are in my own words. Warning wording can change between React versions.
Words used in this lesson
- Hook is a function starting with
usethat React lets you call only while rendering a component or another hook. - Rules of Hooks are the two rules: call hooks only at the top level (not in loops, conditions or nested functions), and only from React functions.
- Ref is a plain box
{ current: value }that React keeps between renders. Changing it does not cause a re-render. - Memoization means remembering the result of a calculation and reusing it when the inputs are the same.
- Reducer is a pure function
(state, action) => newState. It holds all the ways a piece of state can change in one place. - Dispatch is the function you call to send an action to a reducer.
- Custom hook is your own function whose name starts with
useand that calls other hooks. - Suspense is a React feature that shows a fallback (like a spinner) while some part of the tree is not ready.
- Thenable is an object with a
.thenmethod, like a promise. - External store is state that lives outside React (a browser API or a library store) that components can subscribe to.
How hooks work, with a 15-line engine
Look at this component:
function Profile({ admin }) { const [name, setName] = useState("Ada"); const [age, setAge] = useState(30); // ...}useState takes no name. How does React know that the second call belongs to age? By order. For each component instance, React keeps an ordered list of "slots". The first hook call uses slot 0, the second uses slot 1, and so on. Each render, the counter starts at 0 again and walks through the list.
To make this concrete, I wrote a minimal version of the idea. It is not React. It is only the shape of the trick:
function miniReact() { let hooks = [], idx = 0; function useState(init) { const i = idx++; if (!(i in hooks)) hooks[i] = init; return [hooks[i], v => { hooks[i] = v; }]; } function render(C, props) { idx = 0; return C(props); } return { useState, render };}hooks is the list of slots, and idx is "which hook am I on now". Now a component that breaks the rule by calling a hook inside an if:
function Profile({ admin }) { if (admin) { const [role] = R.useState("admin-role"); } const [name, setName] = R.useState("Ada"); return name;}console.log(R.render(Profile, { admin: true })); // first renderconsole.log(R.render(Profile, { admin: false })); // second render, hook skippedB. mini hooks, first render admin=true : Ada second render admin=false (hook skipped): admin-role <- got the role slotOn the first render there were two hooks: role in slot 0, name in slot 1. On the second render the if was false, so useState("Ada") became the first call and read slot 0. It got "admin-role" and not "Ada". Everything after that is shifted by one. This is the whole reason for the rule: the list is matched by position, so the positions must not change.
Real React notices this and tells you. I did the same with real React, a component that calls useState only when a prop is true, rendered first with show={false} and then with show={true}:
A. conditional hook -> thrown: Rendered more hooks than during the previous render. console.error says: React has detected a change in the order of Hooks called by %s. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: ...Real React threw an error and also printed a table of the old and new hook order in the console. (The other direction, calling fewer hooks, is also an error.)
The rules, in plain words
From the official Rules of Hooks page:
- Call hooks at the top level only. Not inside
if, loops,try/catch, event handlers, or functions passed touseMemo,useEffectoruseReducer. Not after an earlyreturn. - Call hooks only from components or custom hooks. Not from plain functions and not from class components.
If you need a hook "sometimes", call it always and use the result sometimes. Or move the conditional part into a separate component, which is only rendered when needed. The linter plugin eslint-plugin-react-hooks checks these rules, and your project template probably has it on already.
useRef: a box that React does not watch
useRef(initial) returns an object { current: initial } that stays the same object for the life of the component. You can read and write current whenever you want. Writing to it does not cause a re-render. I compared it with state:
function RefVsState() { const clicks = useRef(0); const [shown, setShown] = useState(0); L(`render: ref=${clicks.current} state=${shown}`); return <div> <button id="r" onClick={() => { clicks.current += 1; L("ref now " + clicks.current); }}>ref</button> <button id="s" onClick={() => setShown(shown + 1)}>state</button> </div>;}1. mount render: ref=0 state=02. click ref twice (no render) ref now 1 ref now 23. click state (render shows the ref value) render: ref=2 state=1Two clicks on the "ref" button: the ref changed, no render happened. The "state" button caused a render, and that render read the ref's current value, 2. Refs are for values that React does not need to display, but the component needs to remember:
- an interval or timeout ID, so you can clear it later,
- the previous value of something,
- a flag like "has the first fetch run",
- a DOM element (the most common use).
The simple test from the docs: if the value is shown on the screen, it should be state. If it is only bookkeeping, a ref is right. Do not read or write ref.current during rendering, because it makes rendering impure (the docs explain this). Read and write it in event handlers and effects.
Refs to DOM elements
Put a ref on a tag, and React sets current to the real DOM node after the commit:
function Form() { const r = useRef(null); useEffect(() => { r.current?.focus(); }, []); return <input ref={r} />;}This is the way to focus an input, scroll to something, or measure it. It is an escape hatch for things React can not do declaratively. Avoid changing the DOM's structure through a ref, because React will not know and may undo it.
React 19: ref is a plain prop
In older React, a function component could not receive ref as a prop. You had to wrap it in forwardRef. In React 19 a function component receives ref like any other prop. I tested a custom input component:
function FancyInput({ ref, label }) { // no forwardRef return <label>{label}<input ref={ref} /></label>;}function Form() { const r = useRef(null); useEffect(() => { L("ref in effect: " + r.current?.tagName); r.current?.focus(); L("focused is input: " + (document.activeElement === r.current)); }, []); return <FancyInput ref={r} label="Name" />;}C. ref in effect: INPUT | focused is input: trueIt worked with no forwardRef. (forwardRef is still exported from React 19.3.0 for old code, but new code does not need it. The docs for 19 tell you ref is a prop for function components.)
React 19 also lets a ref callback return a cleanup function, which runs when the node is removed. Like an effect, in a few lines:
<div ref={(el) => { L("ref attach " + el.tagName); return () => L("ref cleanup"); }} />callback ref with cleanup: ref attach DIV | ref cleanupMounted, then unmounted: attach, then cleanup. This is neat for things like observers (ResizeObserver) that you start when a node appears and stop when it goes.
useMemo: remember a calculation
useMemo(fn, deps) calls fn during render and stores the result. On the next render, if none of the deps changed (compared with Object.is), it returns the stored result and does not call fn.
function Search() { const [q, setQ] = useState("a"); const [dark, setDark] = useState(false); const items = ["apple", "banana", "cherry"]; const hits = useMemo(() => slowFilter(items, q), [q]); // ...}I counted how often the filter function ran:
4. useMemo: computes after mount = 1 after theme toggle = 1 after q changes = 2Toggling the theme re-rendered the component, but the filter did not run again, because q was the same. Changing q ran it. That is all useMemo does.
When is it worth using? The docs list three honest reasons:
- The calculation is really slow (you measured it, and it is noticeable).
- The result is passed to a component wrapped in
memo, and you need the same object identity so it can skip re-rendering. - The result is a dependency of another hook (like
useEffect), and a new identity each time would re-run it (lesson 2: object dependencies).
For a cheap calculation, useMemo is not free: it costs memory and comparison time. Beginners tend to wrap everything. Do not. Measure first (lesson 7 shows how). And it is a performance hint, not a promise: React may throw away cached values (for example, in the future when offscreen content is dropped), so your code must still be correct if the function runs again. In Strict Mode, the function you pass runs twice on mount in development. I checked:
D. useMemo callback calls in StrictMode on mount: 2useCallback: remember a function
useCallback(fn, deps) is useMemo(() => fn, deps) with a nicer name: it returns the same function object while the dependencies are the same. Why would you want the same function? Because of identity. Recall lesson 1: memo skips a child if its props are equal, and a function created in the body is a new one on each render. So an inline function breaks memo:
const Item = memo(function Item({ onPick }) { L("Item render"); return <button onClick={onPick}>pick</button>; });function NoCb() { const [n, setN] = useState(0); const onPick = () => {}; // new function each render return <div><button onClick={() => setN(n + 1)}>{n}</button><Item onPick={onPick} /></div>;}function WithCb() { const [n, setN] = useState(0); const onPick = useCallback(() => {}, []); // same function each render return <div><button onClick={() => setN(n + 1)}>{n}</button><Item onPick={onPick} /></div>;}I clicked the counter in each, after clearing the log:
5. memo child, inline function prop, parent re-renders Item render6. memo child, useCallback prop, parent re-renders (nothing)With a plain function, the memo child rendered again (the prop changed identity). With useCallback, it did not. The pair works together only when both pieces are present: memo on the child and a stable prop.
useCallback alone does nothing useful. If the child is not wrapped in memo, or does not use the function in a dependency array, the stable identity is wasted. People add useCallback to every function "just in case". The docs say it only matters for the cases above. A newer option is the React Compiler (lesson 7), which adds this kind of memoization automatically at build time, so you write plain code.
useReducer: one place for all the ways state can change
When state has several fields and several different ways to change it, a pile of useState and handlers gets messy. useReducer moves the logic into one reducer function:
function reducer(state, action) { switch (action.type) { case "add": return { ...state, items: [...state.items, action.text] }; case "clear": return { ...state, items: [] }; default: throw new Error("Unknown action: " + action.type); }}function Todo() { const [state, dispatch] = useReducer(reducer, { items: [] }); return ( <div> <button onClick={() => dispatch({ type: "add", text: "task" + (state.items.length + 1) })}>add</button> <button onClick={() => dispatch({ type: "clear" })}>clear</button> <p>{state.items.join(",")}</p> </div> );}I clicked "add" twice, then "clear":
7. reducer: task1,task2 | distinct dispatch functions seen: 1 after clear: ""Two things to notice. First, the event handlers now only describe what happened ({ type: "add" }). The how is in the reducer. Second, I stored every dispatch function I saw across renders, and there was just one. dispatch has a stable identity, so you can pass it to children without useCallback and list it in dependency arrays at no cost.
Reducer rules: it must be pure (no fetching, no random numbers, no mutation: return a new object), and it runs twice in Strict Mode like other render-time code. useReducer is the same engine as useState, with a different shape. I pick it when: the next state depends on the previous in complicated ways, many handlers change the same state, or I want to test the logic without React (a reducer is just a function, so a unit test is three lines).
Custom hooks: share logic, not state
When two components need the same combination of hooks, you write a function. If its name starts with use and it calls other hooks, it is a custom hook:
function useToggle(initial = false) { const [on, setOn] = useState(initial); return [on, () => setOn(o => !o)];}function Toggle({ name }) { const [on, toggle] = useToggle(); return <button onClick={toggle}>{name}:{String(on)}</button>;}// <Toggle name="A" /> and <Toggle name="B" /> side by sideI clicked only A:
E. two components, same hook: A:true B:falseEach call to a hook creates its own state. The hook shares the code, not the data. This is the most common misunderstanding. If you want to share data between components, put the state in a common parent (lesson 6) or in a store.
Why the use prefix? So the linter (and humans) can tell the function calls hooks and therefore must follow the rules of hooks.
Two more custom hooks that I ran, both from real projects:
useLocalStorage reads the saved value once (with the lazy initial state trick from lesson 1) and saves on change:
function useLocalStorage(key, initial) { const [v, setV] = useState(() => { const raw = window.localStorage.getItem(key); return raw === null ? initial : JSON.parse(raw); }); useEffect(() => { window.localStorage.setItem(key, JSON.stringify(v)); }, [key, v]); return [v, setV];}I clicked a theme button, then mounted a new copy of the component:
button: dark | saved: "dark"new mount reads saved value: darkuseDebounce delays a value, so you do not search on every keystroke:
function useDebounce(value, ms) { const [d, setD] = useState(value); useEffect(() => { const id = setTimeout(() => setD(value), ms); return () => clearTimeout(id); }, [value, ms]); return d;}I typed five characters, 5 ms apart, with a 30 ms debounce:
5 fast keystrokes -> searches fired for: [ 'r', 'react' ]The search fired for the first value (on mount) and then only for the final "react". The cleanup cancelled every timer that was replaced. Notice the effect cleanup from lesson 2 doing real work.
Good custom hook habits:
- Name what it does:
useOnlineStatus, notuseThing. - Return what the caller needs: a value, a pair
[value, setValue], or an object. Keep it small. - A custom hook should not secretly depend on a specific component. Pass what it needs as arguments.
- Do not create "lifecycle" hooks like
useMount(fn). They hide the dependency list and fight the linter.
useSyncExternalStore: reading things that live outside React
Some data lives outside React: window.innerWidth, navigator.onLine, or a store library. If you copy it into state with an effect, you may get a flash of wrong data. React gives a dedicated hook, useSyncExternalStore(subscribe, getSnapshot). You give it a function to subscribe (it must return an unsubscribe function) and a function to read the current value.
let value = 1; const listeners = new Set();const store = { get: () => value, subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, set: (v) => { value = v; listeners.forEach(l => l()); },};function Show() { const v = useSyncExternalStore(store.subscribe, store.get); return <p>store = {v}</p>; }<p>store = 1</p> | listeners: 1<p>store = 2</p>listeners after unmount: 0React subscribed on mount, re-rendered when the store changed, and unsubscribed on unmount. It also guarantees the whole tree sees the same value in one render (no "tearing"). Libraries like Zustand and Redux use this hook inside. Most app code will use it through them.
The use API (React 19)
use is not quite a hook, and not quite a normal function. It reads a value from a promise or from a context, and it may be called inside if and loops, which hooks may not. Two uses:
1. Read a promise, with Suspense. If the promise is not finished, the component suspends: React shows the nearest <Suspense fallback> until it resolves, then renders again with the value. If the promise rejects, the nearest error boundary handles it (lesson 8).
const userPromise = new Promise(r => setTimeout(() => r({ name: "Ada" }), 40));function User({ p }) { const u = use(p); return <h1>{u.name}</h1>; }<Suspense fallback={<p>loading...</p>}> <User p={userPromise} /></Suspense>t=0 : <p>loading...</p> | User render attempts: 2t=80ms: <h1>Ada</h1> | User render attempts: 3First the fallback, then the real content once the promise resolved. Note that User was attempted more than once. A component that suspends is called again later, so keep the render pure. The important rule: the promise must be created outside the render, or cached, and passed in. If you create a new promise in the render body, each attempt makes a new pending promise and it can never finish. I tried, with a new promise per render:
new promise each render -> loading | renders: 3Error: This library called use() to suspend in a previous render but did not call use() when it finished.This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-useIt stayed on "loading" and React printed a warning. Real code gets the promise from a framework, a cache, a router loader, or a parent that created it once (for instance a Server Component passing a promise down). Lesson 5 covers data fetching patterns.
2. Read context, even conditionally. useContext must follow the rules of hooks. use(Context) may be called after an early return:
const Theme = createContext("light");function Panel({ open }) { if (!open) return <p>closed</p>; const t = use(Theme); // fine after an early return return <p>theme={t}</p>;}<p>theme=dark</p><p>closed</p>use is allowed in conditions, but it must still be called while rendering a component or hook, not in an event handler or in an effect. Think of it as "unwrap this value, wait if needed".
useId, in one paragraph
useId() returns a string that is unique (in React 19.3.0 server output I got _R_1_ and _R_2_, but the format is not a promise, so never parse it) in the app and the same on the server and the client. Use it for id / htmlFor / aria-describedby pairs, especially in reusable components that may appear many times on a page. Do not use it as a list key. We use it in lesson 4.
Which hook for which job
| Need | Hook |
|---|---|
| Remember something and show it | useState |
| Several related state changes, logic in one place | useReducer |
| Remember something without re-rendering; hold a DOM node | useRef |
| Keep a slow calculation's result | useMemo |
Keep a function's identity for a memo child or a dependency | useCallback |
| Connect to something outside React | useEffect |
| Read a non-React store | useSyncExternalStore |
| Read a promise or context (with Suspense) | use |
| Unique ID for accessibility | useId |
| Share any of the above between components | A custom hook |
Try it
- Write a component with
if (props.big) { const [x] = useState(0) }then togglebig. What error do you get? Move the hook so it is always called. - Make a "stopwatch" that stores the
setIntervalID in auseRefso a Stop button can clear it. Why not use state for the ID? - Count how many times an expensive function runs when you toggle an unrelated piece of state. Wrap it in
useMemo. Count again. - Create
memo(Child)with anonClickprop. Show that the child re-renders on every parent update. Fix it withuseCallback. What happens if you removememobut keepuseCallback? - Convert a counter with
increment,decrementandresetfromuseStatetouseReducer. - Write
useOnlineStatuswithuseSyncExternalStoreandnavigator.onLineand theonline/offlineevents. - Write
useToggleand use it twice in one component. Do the two toggles share state? - Call
use(promiseCreatedInTheComponent). What happens?
Answers
- "Rendered more hooks than during the previous render." Always call the hook and use its result inside the
if. - An ID is bookkeeping and is not shown on screen. Setting it as state would trigger a pointless re-render, while a ref changes silently.
- Without
useMemo, the function runs on every render. With it, only when its inputs change. - With
memoand a plain function, the child re-renders on every parent update. WithuseCallbackit does not. Withoutmemo, the child still re-renders anduseCallbackgains nothing. - State becomes
{ count }. Actions are{type: "inc"},{type: "dec"},{type: "reset"}. The reducer returns new states. subscribeadds the two event listeners and returns a function that removes them.getSnapshotreturnsnavigator.onLine.- No. Each call to a hook has its own state.
- The component stays suspended and React warns, because each render makes a new promise. Create it once outside, or cache it.
Interview questions
What are the rules of hooks, and why do they exist? (Must know)
Call hooks only at the top level of a component or custom hook (not in conditions, loops, nested functions or after an early return), and only from React functions. They exist because React stores hook state in an ordered list per component and matches each call to its slot by position. If the order changes between renders, the slots shift and I get another hook's data. I showed this with a 15-line engine: skipping one useState made the next one read the wrong slot. Real React throws "Rendered more hooks than during the previous render."
What is the difference between useRef and useState? (Must know)
State is for values that appear on screen. Changing it triggers a re-render. A ref is a stable { current } box. Changing current does not trigger a re-render and React does not watch it. I use a ref for timer IDs, previous values and DOM nodes, and I don't read or write it during render. In my test, two ref clicks produced no render, and the next state-driven render showed the updated value.
When should I use useMemo and useCallback? (Must know)
When I measured a slow calculation and want to skip it, when I need a stable object or function so a memo-wrapped child can skip rendering, or when the value is a dependency of an effect and a new identity each render would re-run it. Not for cheap code. I showed that a memo child skipped re-rendering only when it got a useCallback function: with an inline function it rendered again, and with useCallback it did not. And useCallback without memo does nothing for the child.
Is useMemo a guarantee? (Senior signal)
No, it is a performance hint. React may discard cached values, and in Strict Mode it calls my function twice on mount in development (I saw 2 calls). So my code must be correct if the function runs again, and must have no side effects. If it needs to be correct (not just fast), I should not rely on it.
When do I choose useReducer over useState? (Senior signal)
When state has many fields that change together, when many handlers change the same state in different ways, or when I want pure logic that I can unit test without a component. The handlers dispatch small actions that describe what happened, and the reducer holds the how. dispatch is stable, so passing it down is cheap. For a single number or flag, useState is simpler.
Do two components that use the same custom hook share state? (Trap)
No. Each call to the hook creates separate state. A custom hook shares code, not data. In my test, two toggles built with the same useToggle had values A:true and B:false after clicking only A. To share data, I lift state to a common parent, use context, or use an external store.
What is new about refs in React 19? (Senior signal)
Function components receive ref as a normal prop, so forwardRef is no longer needed. forwardRef still exists for old code. Also, ref callbacks can return a cleanup function, which React calls when the node is removed, similar to an effect. I tested both: a custom input got focused through a ref prop with no wrapper, and a callback ref logged "attach" and then "cleanup" on unmount.
What is the use API, and how is it different from a hook? (Senior signal)
use reads a promise or a context. With a promise, it suspends the component until the promise resolves, and the nearest Suspense shows a fallback. Unlike hooks, it can be called conditionally and in loops. It still has to be called during render. The promise must be stable across renders (created outside, cached, or passed from a parent that made it once). When I created a new promise in the render body, the component never finished and React warned about incorrect use of use().
How do I read browser state like navigator.onLine safely? (Senior signal)
With useSyncExternalStore(subscribe, getSnapshot). subscribe registers a listener and returns the unsubscribe function, and getSnapshot reads the current value. React subscribes, re-renders on change, unsubscribes on unmount, and makes sure all components see the same value in one render. Copying it into state with an effect can cause one frame of stale data and tearing. In my test the store listener count was 1 while mounted and 0 after unmount.
Why does a custom hook's name need to start with use? (Must know)
It is a convention that tools rely on. The lint rules know that a function with that name calls hooks and so must follow the rules of hooks, and they apply the checks to its callers. If I call hooks inside a function named getTheme, the linter won't enforce the order rules for it, and I could call it conditionally by accident.
How would you debounce a search input in React? (Senior signal)
I write a useDebounce(value, ms) hook with state and an effect: set a timeout that updates the debounced state, and clear it in the cleanup. The component uses the debounced value for the fetch. In my test, five fast keystrokes produced searches only for the first value and the last. An alternative in React 19 is useDeferredValue, which lets React defer the less urgent update without a fixed delay, but it doesn't cancel network requests the way a debounce does.
Can a hook be called inside an event handler or a useEffect callback? (Trap)
No. Hooks have to run during rendering in the same order every time. Inside an event handler or effect callback, the call happens later and in no fixed order. If I need a hook's value in a handler, I call the hook at the top level and use the returned value (or function) inside the handler.
Key takeaways
- Hooks are matched by call order, so call them unconditionally at the top level. Breaking the order gives "Rendered more hooks than during the previous render."
useRefholds a value that survives renders without causing one, and holds DOM nodes. In React 19refis a normal prop for function components.useMemoanduseCallbackkeep values and functions stable. Use them for slow work,memochildren and effect dependencies, after measuring.useReducergathers all state changes in a pure function; itsdispatchis stable.- A custom hook shares logic and not state. Each call gets its own.
useSyncExternalStorereads outside stores safely.useunwraps promises and context, can be conditional, and needs a stable promise.
What's next
Next: Forms, Events and Controlled Inputs, lesson 4. We use these hooks to build forms: controlled and uncontrolled inputs, the React 19 form action, useActionState, useFormStatus, useOptimistic, validation and accessibility. I ran a form in a real browser and recorded the pending state, the error, and the reset behaviour. Practise now: take a component of yours that has a useEffect with a setInterval and move it into a custom hook with a clear name.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Hooks in Depth: Effects, Refs and the Rules · Senior Frontend Depth
- “The API Is Fast but the UI Is Slow”: How to Solve It · Scenario Debugging
- Frontend and React Scenario Bank · Scenario Debugging
Part of the study guide
Related series