Skip to content
masterclassBuddyTech Buzz5 fresh blogs · 5:00 AM IST

Technology handbook

React 19+ Hooks — State, Effects & Actions

A complete React 19+ Hooks reference covering state, Effects, refs, context, Actions, concurrency, external stores and custom Hook design.

Topics
65
Sections
13

Rules and foundations

Rules of Hooks

Call Hooks only at the top level of React functions and keep their order stable across renders.

  • React matches each Hook call to its stored state by call order, so the order must be identical on every render.
  • No Hooks inside conditions, loops, nested functions, try/catch or after an early return.
  • Only call Hooks from function components or custom Hooks — never from plain functions or class components.

A conditional subscription: Instead of if (isOnline) useEffect(…), call useEffect unconditionally and put if (!isOnline) return; inside it.

Custom Hooks

Extract reusable stateful logic into functions whose names begin with use.

  • A custom Hook shares logic, not state: two components calling useOnlineStatus() each get their own copy.
  • Name it after what it does (useChatRoom, useMediaQuery), not after the Hook it wraps (useMount).
  • It re-runs on every render of its component, so it always receives the latest props.

Example: hooks/use-media-query.ts

import { useSyncExternalStore } from "react";

export function useMediaQuery(query: string) {
  return useSyncExternalStore(
    callback => {
      const list = window.matchMedia(query);
      list.addEventListener("change", callback);
      return () => list.removeEventListener("change", callback);
    },
    () => window.matchMedia(query).matches,
    () => false,
  );
}

Reusing a network-status check: function useOnlineStatus() { return useSyncExternalStore(subscribe, () => navigator.onLine, () => true) } used by a save button and a status bar.

State hooks

useState

Store simple component state and update it with values or updater functions.

  • const [value, setValue] = useState(initial) — the setter schedules a re-render; it does not change value in the running code.
  • React skips the re-render when the new value is Object.is equal to the old one.
  • Objects and arrays must be replaced, not mutated: setItems([...items, item]).

Example: components/signup-form.tsx

"use client";
import { useState } from "react";

export function SignupForm() {
  const [email, setEmail] = useState("");
  const [agreed, setAgreed] = useState(false);
  // Derived, not stored: it can never disagree with the fields.
  const canSubmit = email.includes("@") && agreed;

  return (
    <form>
      <input value={email} onChange={e => setEmail(e.target.value)} placeholder="you@example.com" />
      <label>
        <input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)} /> I agree
      </label>
      <button disabled={!canSubmit}>Create account</button>
    </form>
  );
}

A controlled input: const [name, setName] = useState(''); <input value={name} onChange={e => setName(e.target.value)} />.

useReducer

Model related state transitions with a pure reducer and explicit action objects.

  • const [state, dispatch] = useReducer(reducer, initialArg) — events dispatch actions, the reducer decides the next state.
  • dispatch is stable across renders, so it can be passed down without breaking memoization.
  • Reducers are easy to unit-test because they are plain functions of (state, action).

Example: lib/cart-reducer.ts

export type Line = { id: string; name: string; price: number; qty: number };
export type CartAction =
  | { type: "added"; item: Omit<Line, "qty"> }
  | { type: "removed"; id: string }
  | { type: "cleared" };

export function cartReducer(lines: Line[], action: CartAction): Line[] {
  switch (action.type) {
    case "added": {
      const existing = lines.find(l => l.id === action.item.id);
      return existing
        ? lines.map(l => (l.id === action.item.id ? { ...l, qty: l.qty + 1 } : l))
        : [...lines, { ...action.item, qty: 1 }];
    }
    case "removed":
      return lines.filter(l => l.id !== action.id);
    case "cleared":
      return [];
  }
}

A shopping cart: dispatch({ type: 'added', product }) and dispatch({ type: 'removed', id }) keep every cart rule in one cartReducer.

useState lazy initialization

Compute an expensive initial state once by passing an initializer function.

  • useState(createTodos) passes the function; useState(createTodos()) calls it on every render.
  • React calls the initializer only during the first render.
  • Good for reading localStorage or building an expensive structure once.

Restoring a draft: useState(() => localStorage.getItem('draft') ?? '') reads storage once instead of on every keystroke.

useState functional updates

Use the previous state argument when multiple updates depend on the current value.

  • setCount(c => c + 1) uses the latest queued value, not the snapshot from this render.
  • Three setCount(count + 1) calls in one handler add 1; three updater calls add 3.
  • Updaters must be pure — React may call them twice in Strict Mode.

A double-click counter: onClick={() => { setLikes(l => l + 1); setLikes(l => l + 1); }} adds two likes reliably.

useReducer lazy initialization

Initialize reducer state through init and reset it predictably from an action.

  • useReducer(reducer, userId, createInitialState) calls the init function once with userId.
  • Reset by dispatching { type: 'reset', payload } and returning init(payload).
  • Avoid useReducer(reducer, createInitialState(userId)), which runs every render.

A form with a reset button: The reducer's reset case returns init(initialValues), restoring the original fields in one dispatch.

useReducer reducer purity

Keep reducers deterministic and free of network calls, mutations and other side effects.

  • A reducer receives (state, action) and returns new state — no fetch, timers or random values.
  • Copy before changing: return { ...state, items: [...state.items, action.item] }.
  • Strict Mode calls reducers twice to expose impurity.

Moving side effects out: Instead of saving inside the reducer, an Effect watches state.items and saves them to the server.

Effect hooks

useEffect

Synchronize a component with an external system after rendering.

  • It runs after the screen updates, then again whenever a dependency changes.
  • Return a cleanup function to undo the setup — React runs it before the next run and on unmount.
  • If there is no external system (network, DOM API, timer, socket), you probably do not need an Effect.

Example: lib/chat.ts

export function createConnection(roomId: string) {
  const socket = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);
  return {
    onMessage(handler: (text: string) => void) {
      socket.addEventListener("message", e => handler(String(e.data)));
    },
    disconnect: () => socket.close(),
  };
}

Connecting to a chat room: useEffect(() => { const c = createConnection(roomId); c.connect(); return () => c.disconnect(); }, [roomId]).

useLayoutEffect

Measure or update layout before the browser paints when a visual effect requires it.

  • It blocks the browser from painting until it finishes, so keep it short.
  • Use it to measure layout (e.g. getBoundingClientRect) and adjust before the user sees a flicker.
  • Prefer useEffect; switch only when you can see a visual jump.

Positioning a tooltip: Measure the tooltip's height in useLayoutEffect, then place it above the button if it would overflow the bottom of the screen.

useInsertionEffect

Insert styles before layout effects, primarily for CSS-in-JS library authors.

  • It runs before layout effects, so injected <style> rules exist when layout is measured.
  • It cannot update state, and refs are not attached yet when it runs.
  • Application code almost never needs it.

A CSS-in-JS library: The library's useCSS(rule) inserts the generated rule with useInsertionEffect so components never measure unstyled DOM.

useEffectEvent

Read fresh props and state from an Effect without making the Effect reactive to those values.

  • Logic inside an Effect Event reads the latest values but does not cause the Effect to re-run.
  • Leave it out of the dependency array — it is not reactive.
  • Split "what should re-sync" (Effect deps) from "what should only be read" (Effect Event).

Example: components/chat-room.tsx

"use client";
import { useEffect, useEffectEvent } from "react";
import { createConnection } from "@/lib/chat";
import { track } from "@/lib/analytics";

export function ChatRoom({ roomId, plan }: { roomId: string; plan: string }) {
  const onConnected = useEffectEvent(() => {
    track("room_joined", { roomId, plan }); // always the current plan
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", onConnected);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // plan is not a dependency

  return <h2>Room {roomId}</h2>;
}

Logging a visit with the current cart size: const onVisit = useEffectEvent(url => log(url, cartCount)); useEffect(() => onVisit(url), [url]) — changing cartCount alone does not log again.

Hook dependency arrays

Declare every reactive value an Effect or memoized calculation reads from its scope.

  • List every prop, state and value declared in the component that the code reads.
  • No array runs after every render; [] runs once after mount.
  • To remove a dependency, change the code so it no longer reads it — never silence the linter.

Fixing a stale value: An Effect reading roomId with [] keeps the first room; adding [roomId] reconnects when the room changes.

useEffect cleanup

Disconnect subscriptions, remove listeners and abort work when dependencies change or components unmount.

  • Cleanup runs before every re-run of the Effect, not only on unmount.
  • Each setup should have a matching cleanup: subscribe/unsubscribe, connect/disconnect, add/remove listener.
  • Strict Mode runs setup → cleanup → setup in development to test this.

Removing a scroll listener: useEffect(() => { window.addEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll); }, []).

useEffect dependency removal

Move derived calculations and event logic out of Effects instead of suppressing dependency warnings.

  • Values calculated from props or state belong in render, not in an Effect plus state.
  • Logic triggered by a click belongs in the event handler.
  • Create objects and functions inside the Effect so they are not dependencies.

Removing an extra render: Replace useEffect(() => setFullName(first + ' ' + last), [first, last]) with const fullName = first + ' ' + last.

useLayoutEffect server rendering

Avoid layout effects in server-rendered output or isolate them to a client-only boundary.

  • Layout effects do not run on the server, so the first HTML lacks their measurements.
  • Render a placeholder until mounted, or move the measuring component into a client-only part.
  • Often CSS (e.g. position: sticky) removes the need entirely.

Server-rendered tooltip: The tooltip renders hidden on the server and is positioned after hydration, avoiding a mismatch warning.

useEffectEvent limitations

Call Effect Events from Effects and do not use them as a general-purpose event handler replacement.

  • Call Effect Events only from inside Effects.
  • Do not pass them to other components or Hooks.
  • Declare them right next to the Effect that uses them.

Keeping analytics consistent: An onConnected Effect Event shows a notification with the current theme, called only from the connection Effect.

useInsertionEffect ordering

Understand its early ordering and reserve it for style injection integrations.

  • Order: insertion effects → layout effects → paint → effects.
  • That order lets style rules exist before any layout effect measures.
  • It does not run on the server; collect styles separately for SSR.

Building a styling library: A styled() helper inserts each new class with useInsertionEffect and dedupes rules already inserted.

Ref hooks

useRef

Keep a mutable value or DOM reference without causing a render when it changes.

  • ref.current can be read and changed freely; changing it does not re-render.
  • Do not read or write ref.current during render, except for lazy initialisation.
  • Pass it to a JSX ref attribute to get the DOM node.

Example: components/stopwatch.tsx

"use client";
import { useRef, useState } from "react";

export function Stopwatch() {
  const [elapsed, setElapsed] = useState(0);
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);

  function start() {
    if (timer.current) return; // already running
    timer.current = setInterval(() => setElapsed(s => s + 1), 1000);
  }
  function stop() {
    if (timer.current) clearInterval(timer.current);
    timer.current = null;
  }

  return <p>{elapsed}s <button onClick={start}>Start</button> <button onClick={stop}>Stop</button></p>;
}

Storing an interval id: const id = useRef(null); id.current = setInterval(tick, 1000); … clearInterval(id.current).

ref as a prop

Receive refs directly in React 19 function components without forwardRef.

  • In React 19, function components receive ref as a normal prop: function Input({ ref, ...props }).
  • forwardRef still works but is no longer needed and will be deprecated.
  • Ref callbacks can return a cleanup function, called when the element is removed.

Example: components/text-field.tsx

import type { Ref } from "react";

export function TextField({ label, ref, ...rest }: { label: string; ref?: Ref<HTMLInputElement> } & React.InputHTMLAttributes<HTMLInputElement>) {
  return (
    <label>
      {label}
      <input ref={ref} {...rest} />
    </label>
  );
}

A design-system input: function TextField({ ref, label }) { return <label>{label}<input ref={ref} /></label> } — a parent can focus it with no wrapper.

useImperativeHandle

Expose a narrow imperative API through a ref instead of leaking a child implementation.

  • useImperativeHandle(ref, () => ({ focus, scrollIntoView }), []) replaces the DOM node with your own object.
  • Expose only actions such as focus, play or reset — not state that should be props.
  • Prefer props; use it only for things props cannot express.

A video player component: The parent calls playerRef.current.play(), while the <video> element and its other methods stay private.

useRef DOM focus

Focus, measure or scroll to a DOM node without storing that node in render state.

  • Attach with <input ref={inputRef} />, then call inputRef.current.focus() in an event or Effect.
  • ref.current is null until the element mounts.
  • Use refs for focus, text selection, scrolling and media playback.

Focusing a search box: Pressing / runs searchRef.current?.focus(), like GitHub's keyboard shortcut.

useRef instance values

Store timers, previous values and mutable handles that should survive renders without re-rendering.

  • Values in a ref survive re-renders but do not trigger them.
  • Good for timer ids, previous values and AbortControllers.
  • If the screen must show the value, it belongs in state instead.

A stopwatch: intervalRef.current = setInterval(…) on Start and clearInterval(intervalRef.current) on Stop.

useImperativeHandle dependency list

Recreate an exposed handle only when its reactive dependencies change.

  • Without deps the handle is recreated on every render.
  • List values used by the exposed methods, just like Effect deps.
  • Most handles use refs inside, so [] is often correct.

A form with `validate()`: The exposed validate reads the rules prop, so rules goes in the deps list.

Context hooks

useContext

Read the nearest context value without passing it through every intermediate component.

  • It reads the value from the closest <Context> provider above the component.
  • When the provider's value changes, every component using that context re-renders.
  • Without a provider it returns the default passed to createContext.

Example: components/theme-provider.tsx

"use client";
import { createContext, useState, type ReactNode } from "react";

type Theme = "light" | "dark";
export const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const toggle = () => setTheme(t => (t === "light" ? "dark" : "light"));
  return <ThemeContext value={{ theme, toggle }}>{children}</ThemeContext>;
}

Theme switching: <ThemeContext value="dark"> in the layout, then const theme = useContext(ThemeContext) in any button.

use as context

Read context with React's use API when conditional or loop-based reading is useful.

  • use(ThemeContext) works like useContext but may be called inside if and loops.
  • It is still a Hook-like API: call it in a component or Hook, not in a plain function.
  • In React 19 the provider can be written as <ThemeContext value={…}> without .Provider.

Reading context only when needed: if (!showBadge) return null; const user = use(UserContext); — the context is read only on the branch that needs it.

Context value identity

Keep provider values stable or split contexts so unrelated consumers do not re-render.

  • A new object each render (value={{ user, logout }}) re-renders every consumer.
  • Memoize it with useMemo, or split rarely-changing and frequently-changing data into two contexts.
  • The React Compiler can memoize the value object for you.

An auth context: const value = useMemo(() => ({ user, logout }), [user]) stops typing in an unrelated input from re-rendering the nav.

Action hooks

useActionState

Connect an async action to its result, pending state and form submission lifecycle.

  • const [state, formAction, isPending] = useActionState(action, initialState).
  • The action receives the previous state first, then the form data: async (prev, formData) => ….
  • With a Server Function, the returned state renders even before JavaScript loads.

Example: app/signup/actions.ts

"use server";
import { redirect } from "next/navigation";
import { z } from "zod";

const schema = z.object({ email: z.email(), password: z.string().min(12) });
export type SignupState = { errors?: { email?: string; password?: string } };

export async function signup(_prev: SignupState, formData: FormData): Promise<SignupState> {
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    const f = parsed.error.flatten().fieldErrors;
    return { errors: { email: f.email?.[0], password: f.password?.[0] } };
  }
  // await db.user.create({ data: parsed.data });
  redirect("/welcome");
}

Showing a sign-up error: signup returns { error: 'Email taken' }; the form shows state.error and disables the button while isPending.

useFormStatus

Read a parent form's pending state from a nested submit control.

  • Import it from react-dom; it only reads the status of the <form> it is rendered inside.
  • It returns { pending, data, method, action }.
  • Calling it in the same component that renders the <form> always returns pending: false.

A reusable submit button: function Submit() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button> }.

useOptimistic

Display an optimistic state while an Action is awaiting its real result.

  • const [optimistic, addOptimistic] = useOptimistic(state, updateFn) shows a temporary value during an Action.
  • When the Action finishes, React switches back to the real state automatically.
  • Call addOptimistic inside an Action or startTransition.

Example: app/posts/actions.ts

"use server";
import { updateTag } from "next/cache";

export async function likePost(postId: string) {
  // await db.like.create({ data: { postId } });
  updateTag(`post-${postId}`);
}

Sending a chat message: The message appears instantly with a "Sending…" label; when the server responds it is replaced by the saved message.

useActionState server actions

Connect a form action to server-side validation and return structured field errors.

  • Return field errors from the Server Function, e.g. { errors: { email: 'Invalid email' } }.
  • Validate with a schema (such as Zod) on the server — client validation can be bypassed.
  • Pass permalink as the third argument to keep the form working before hydration.

Server-validated contact form: const [state, action, pending] = useActionState(sendMessage, { errors: {} }) shows state.errors.email under the input.

useFormStatus data

Read pending, data, method and action for reusable controls nested inside a form.

  • data is the FormData being submitted, so you can show what is being saved.
  • method is 'get' or 'post', and action is the function or URL.
  • The values are only set while the form is pending.

Showing what is being sent: const { pending, data } = useFormStatus(); pending && <p>Requesting {data?.get('username')}…</p>.

useOptimistic rollback

Return to the source of truth automatically when an optimistic Action fails.

  • If the Action throws, the optimistic value disappears when the transition ends.
  • Show an error message so users know the change failed.
  • Keep the real state as the single source of truth.

A like button that can fail: The heart fills instantly; if the request fails it empties again and a toast says "Couldn't like post".

Concurrency hooks

useTransition

Mark updates as non-urgent and expose their pending state.

  • const [isPending, startTransition] = useTransition(); updates inside startTransition are interruptible.
  • Typing into an input cannot be a transition — the input must update immediately.
  • Since React 19, startTransition accepts async functions, which makes them Actions.

Example: components/tabs.tsx

"use client";
import { useState, useTransition } from "react";
import { Posts } from "./posts"; // slow to render
import { About } from "./about";

export function Tabs() {
  const [tab, setTab] = useState<"about" | "posts">("about");
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <button onClick={() => startTransition(() => setTab("about"))}>About</button>
      <button onClick={() => startTransition(() => setTab("posts"))}>Posts</button>
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {tab === "about" ? <About /> : <Posts />}
      </div>
    </>
  );
}

Switching heavy tabs: startTransition(() => setTab('posts')) keeps the old tab clickable while the slow Posts tab renders, with isPending dimming it.

useDeferredValue

Let a slow rendering path lag behind an urgent value such as text input.

  • The deferred value first renders with the old value, then re-renders in the background with the new one.
  • Combine it with memo on the slow child, or nothing is gained.
  • There is no fixed delay — it adapts to the device's speed.

Example: components/product-search.tsx

"use client";
import { useDeferredValue, useState } from "react";
import { ResultList } from "./result-list";

export function ProductSearch() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const stale = query !== deferredQuery;
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <div style={{ opacity: stale ? 0.5 : 1 }}><ResultList query={deferredQuery} /></div>
    </>
  );
}

Search-as-you-type: const deferred = useDeferredValue(query); <Results query={deferred} /> keeps typing smooth while results catch up.

useTransition pending state

Disable duplicate navigation or show feedback while a transition is pending.

  • isPending is true until all updates in the transition have rendered.
  • Use it to dim content or disable a button rather than replacing the page with a spinner.
  • Updates in a transition do not show Suspense fallbacks for content already on screen.

Preventing double navigation: A tab button calls startTransition(() => router.push(href)) and shows isPending as a subtle loading bar.

useDeferredValue initialValue

Provide a first deferred value for a loading or filtered view during the initial render.

  • useDeferredValue(value, initialValue) renders initialValue first, then the real value in the background.
  • Useful when the first render should be fast, e.g. an empty list before results.
  • Added in React 19.

Fast first paint for search results: useDeferredValue(query, '') shows an empty result list immediately, then fills it once React has time.

Async hooks

use

Read a Promise or context value during rendering and coordinate it with Suspense.

  • use(promise) suspends the component until the promise resolves and shows the nearest <Suspense> fallback.
  • Unlike other Hooks it can be called inside if and loops.
  • Promises should come from a Server Component or a cache — not be created during the client render.

Example: app/posts/[id]/page.tsx

import { Suspense } from "react";
import { Comments } from "./comments";
import { getPost, getComments } from "@/lib/data";

export default async function PostPage({ params }: PageProps<"/posts/[id]">) {
  const { id } = await params;
  const post = await getPost(id);          // needed for the first paint
  const commentsPromise = getComments(id);  // streamed in later
  return (
    <article>
      <h1>{post.title}</h1>
      <Suspense fallback={<p>Loading comments…</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}

Streaming comments into a post: The server passes commentsPromise down; <Comments> calls use(commentsPromise) inside <Suspense fallback={<Spinner />}>.

use with promises

Read a stable promise, suspend while pending and handle rejection with an Error Boundary.

  • Create the promise in a Server Component or cache it, so re-renders reuse the same promise.
  • A rejected promise goes to the nearest error boundary.
  • Call use inside <Suspense> to show a fallback while it is pending.

Loading a user profile: const user = use(userPromise) inside <Suspense fallback={<Skeleton />}>, with an error boundary showing "Couldn't load profile".

use with conditional context

Read context conditionally or in a loop where useContext's top-level rule would be restrictive.

  • use(Context) can be called after an early return, unlike useContext.
  • It reads from the closest provider, same as useContext.
  • This avoids reading context in components that bail out early.

An optional admin panel: if (!isAdmin) return null; const perms = use(PermissionsContext); reads permissions only for admins.

External store hooks

useSyncExternalStore

Subscribe to an external store with consistent snapshots across client and server rendering.

  • useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?).
  • getSnapshot must return the same value while the store is unchanged, or React loops.
  • Define subscribe outside the component so it is not resubscribed every render.

Example: hooks/use-online-status.ts

import { useSyncExternalStore } from "react";

function subscribe(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
}

Following browser online status: useSyncExternalStore(subscribe, () => navigator.onLine, () => true) with subscribe listening to online and offline events.

useDebugValue

Label custom Hook state in React DevTools without changing runtime behavior.

  • useDebugValue(isOnline ? 'Online' : 'Offline') inside a custom Hook shows in React DevTools.
  • It is only useful in shared custom Hooks.
  • It has no effect in production.

Labelling a shared Hook: Inspecting a component in DevTools shows OnlineStatus: "Offline" next to the Hook.

useSyncExternalStore server snapshot

Supply a stable server snapshot so external-store content hydrates consistently.

  • The third argument returns the value to use during server rendering and hydration.
  • It must be the same on server and client during hydration.
  • Without it, server rendering throws for that component.

Online status with SSR: getServerSnapshot = () => true assumes online on the server, then updates on the client if the user is offline.

useSyncExternalStore browser subscriptions

Subscribe and unsubscribe to an external source without tearing during concurrent rendering.

  • subscribe(callback) must return an unsubscribe function.
  • React calls callback to re-check getSnapshot whenever the store reports a change.
  • It prevents "tearing" — two parts of the screen showing different store values.

Tracking window width: Subscribe to resize and return window.innerWidth as the snapshot to drive a responsive layout.

useDebugValue formatter

Format custom Hook labels lazily so development diagnostics do not add production work.

  • Pass a second argument: useDebugValue(date, d => d.toDateString()).
  • The formatter runs only when DevTools inspects the component.
  • Use it when formatting is expensive.

Formatting a timestamp label: A useLastSaved Hook shows Saved: Tue Oct 14 in DevTools without formatting on every render.

Utility hooks

useId

Generate stable unique IDs for accessibility relationships across server rendering and hydration.

  • The ID is the same on server and client, so it does not cause hydration mismatches.
  • Do not use it for list keys — keys should come from your data.
  • Call it once and add suffixes for related elements: ${id}-hint.

Example: components/password-field.tsx

"use client";
import { useId } from "react";

export function PasswordField() {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>Password</label>
      <input id={id} type="password" aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>At least 12 characters.</p>
    </div>
  );
}

An accessible form field: const id = useId(); <label htmlFor={id}>Email</label><input id={id} aria-describedby={${id}-hint} />.

useId accessibility

Connect labels, descriptions and controls with deterministic IDs rather than random values.

  • Connect <label htmlFor>, aria-describedby and aria-labelledby with generated ids.
  • Each component instance gets its own id, so reused fields never clash.
  • identifierPrefix on the root avoids clashes between several React roots on one page.

A password field with a hint: <input aria-describedby={hintId} /> <p id={hintId}>At least 12 characters</p> reads the hint aloud in screen readers.

Performance hooks

useMemo

Cache a calculation when measurement shows recomputation is costly.

  • useMemo(() => compute(a, b), [a, b]) recomputes only when a dependency changes.
  • Measure first — most calculations are fast enough without it.
  • It also keeps an object's identity stable for a memo child or an Effect dependency.

Example: components/invoice-table.tsx

"use client";
import { useMemo, useState } from "react";

type Row = { id: string; customer: string; amount: number; date: string };

export function InvoiceTable({ rows }: { rows: Row[] }) {
  const [filter, setFilter] = useState("");
  const [dark, setDark] = useState(false);

  const visible = useMemo(
    () => rows
      .filter(r => r.customer.toLowerCase().includes(filter.toLowerCase()))
      .toSorted((a, b) => b.date.localeCompare(a.date)),
    [rows, filter],
  );

  return (
    <div className={dark ? "dark" : ""}>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <button onClick={() => setDark(d => !d)}>Theme</button>
      <table><tbody>{visible.map(r => <tr key={r.id}><td>{r.customer}</td><td>{r.amount}</td></tr>)}</tbody></table>
    </div>
  );
}

Filtering a large list: const visible = useMemo(() => todos.filter(t => matches(t, filter)), [todos, filter]) skips re-filtering when only the theme changes.

useCallback

Cache a function identity when it matters to memoized children or Hook dependencies.

  • useCallback(fn, deps) is useMemo(() => fn, deps).
  • It helps only when the function goes to a memo child or is used as a Hook dependency.
  • Use the updater form inside to drop state from the deps: setTodos(t => [...t, item]).

A memoized list row: const onSelect = useCallback(id => setSelected(id), []) lets each memo(Row) skip re-rendering when the parent updates.

useMemo and the React Compiler

Know when automatic memoization makes manual useMemo and useCallback unnecessary.

  • The compiler memoizes values and functions automatically at build time.
  • Existing useMemo keeps working; remove it gradually where the compiler covers it.
  • Keep manual memo when a value must be stable as an Effect dependency.

Simplifying a codebase: After enabling the compiler, a team removes useCallback wrappers from event handlers without measurable slowdowns.

useMemo cache semantics

Treat memoization as a performance optimization, never as storage for required application state.

  • React may throw away cached values (e.g. offscreen components), so code must work without the cache.
  • Never store data you would lose — use state for that.
  • The calculation must be pure.

A safe memoized sort: useMemo(() => [...rows].sort(byDate), [rows]) copies before sorting so the original array is never mutated.

useCallback and memo

Use callback identity with memoized children only when it prevents measured work.

  • memo(Child) skips re-rendering only if every prop is equal, functions included.
  • Without useCallback, a new function each render defeats memo.
  • Check with the React Profiler that the child was actually slow.

A big table with row actions: A stable onDelete via useCallback lets 1,000 memo(Row) components skip re-rendering when a filter changes elsewhere.

Testing Hooks

Hook testing

Test Hook behavior through a user-facing component rather than coupling tests to private implementation.

  • Test what the user sees: render a component, click, assert the screen.
  • Mock the network (e.g. MSW), not the Hook itself.
  • Wrap asynchronous updates in await with findBy* queries.

Testing a counter Hook: Render <Counter />, click "Add" twice with user.click, then expect the text 2.

Hook patterns

Hooks and StrictMode

Development mounts, unmounts and remounts a component to surface impure renders and missing cleanup.

  • In development it renders twice, runs Effects twice and calls ref callbacks twice.
  • None of this happens in production.
  • If something breaks on the second run, add or fix the cleanup.

Finding a missing unsubscribe: Duplicate chat messages in dev reveal that the Effect never removed its socket listener.

eslint-plugin-react-hooks

Let the lint rules enforce call order and dependency completeness instead of reviewing both by eye.

  • rules-of-hooks blocks Hooks in conditions and loops.
  • exhaustive-deps warns about missing Effect, useMemo and useCallback dependencies.
  • Newer versions also include React Compiler rules such as purity checks.

Catching a bug in review: The linter flags useEffect(() => fetchUser(id), []) with "missing dependency: id" before it ships.

renderHook in tests

Exercise a hook through a test renderer rather than through a component written only for the test.

  • renderHook(() => useCounter()) from Testing Library returns { result }.
  • Wrap updates in act, and pass wrapper to provide context providers.
  • Prefer component tests unless the Hook is a reusable library API.

Testing a shared Hook: const { result } = renderHook(() => useToggle()); act(() => result.current.toggle()); expect(result.current.on).toBe(true).

AbortController in useEffect

Cancel an in-flight request from the cleanup function so a superseded response cannot win.

  • Create the controller inside the Effect and pass signal to fetch.
  • Call controller.abort() in the cleanup function.
  • Ignore the AbortError it causes, since it is expected.

Example: components/user-search.tsx

"use client";
import { useEffect, useState } from "react";

export function UserSearch() {
  const [query, setQuery] = useState("");
  const [users, setUsers] = useState<{ id: string; name: string }[]>([]);

  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setUsers)
      .catch(err => { if (err.name !== "AbortError") throw err; });
    return () => controller.abort();
  }, [query]);

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
    </>
  );
}

Search requests on each keystroke: Typing "rea" then "react" aborts the "rea" request, so its late response cannot overwrite the newer results.

Effect race conditions

Ignore a resolved response whose Effect has already been cleaned up, using a captured flag or a signal.

  • Responses can arrive out of order; the last request sent is not always the last to finish.
  • Set let ignore = false in the Effect and ignore = true in cleanup, then check it before setState.
  • Frameworks and data libraries (TanStack Query, use) handle this for you.

Switching profiles quickly: Clicking Alice then Bob shows Bob even if Alice's request finishes last, because Alice's Effect was ignored.

Callback refs

Attach and detach behaviour as a node mounts by passing a function ref instead of writing an Effect.

  • ref={node => { … }} runs when the node mounts and, in React 19, can return a cleanup.
  • Good for measuring or observing an element without a separate Effect.
  • Define it with useCallback or outside render if it must not re-run every render.

Observing element size: ref={node => { const ro = new ResizeObserver(update); ro.observe(node); return () => ro.disconnect(); }}.

useContext default values

Know what a consumer receives with no provider above it, and whether that default should throw instead.

  • The default is used only when no provider is above the component.
  • Passing undefined as a provider value does not fall back to the default.
  • A null default plus a custom Hook that throws catches a missing provider early.

A safe `useAuth` Hook: const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be inside <AuthProvider>');.

Custom Hook naming

The use prefix is what makes the lint rules, the compiler and the runtime treat a function as a Hook.

  • Names must start with use followed by a capital letter: useFetch, not usefetch.
  • Functions that do not call Hooks should not use the prefix.
  • The linter checks the rules only for functions it recognises as Hooks or components.

Renaming a helper: getSorted(items) calls no Hooks, so it stays a plain function instead of useSorted.

Hooks and the React Compiler

Code that follows the rules is memoized for you; code that breaks them silently opts out of that.

  • The compiler assumes components and Hooks are pure and props/state are not mutated.
  • Components it cannot optimize are skipped, not broken.
  • Fix lint errors from the compiler rules to get those components optimized.

Unlocking automatic memoization: A component mutating a prop array is skipped; switching to [...items].sort() lets the compiler memoize it.

Selector Hooks over external stores

Pair useSyncExternalStore with a selector so a component subscribes to one slice, not the whole store.

  • A selector returns only the slice the component needs, e.g. state => state.cart.count.
  • The component re-renders only when that slice changes.
  • Return primitives or stable references, or every update looks like a change.

A cart badge: const count = useStore(s => s.cart.items.length) updates the badge without re-rendering on unrelated store changes.

Deriving instead of storing

A value computable from props and state needs no Hook: an extra state copy is a second source of truth.

  • Calculate values during render: const total = items.reduce(…).
  • State copies of props drift out of sync when the props change.
  • If the calculation is slow, wrap it in useMemo instead of storing it.

A checkout total: total is computed from items each render, so it can never disagree with the item list.

Passing Hook results down

Prefer handing a child the value a Hook returned over calling the same Hook again inside it.

  • Calling the same data Hook in many children can mean extra subscriptions or fetches.
  • Call it once in the parent and pass the value as a prop.
  • For deep trees, use context instead of many prop levels.

A product page: The page calls useProduct(id) once and passes product to <Gallery>, <Price> and <Reviews>.

Ask follow-up questions on any topic in your dashboard.

Start learning