NoWaterProgramming

React Hooks Guide: useState, useEffect, and When Not to Use an Effect

The hooks you use daily, the rules that make them work, and the failure they cause most often: reaching for useEffect when the answer is a computed value, an event handler, or an external store.

15 min read
Share:

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:

  1. 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.
  2. 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-hooks
bash

The 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>
  );
}
tsx

Use 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 latest
tsx

The 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") || "{}")
);
tsx

useReducer 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 };
  }
}
tsx

A 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 changes
tsx

Every 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]);
tsx

React 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]);
tsx

Responding 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 setState had 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]);
  // ...
}
tsx

The 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
  );
}
tsx

Reading 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..." />;
}
tsx

The 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]);
}
tsx

The 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), []);
tsx

Wrapping 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 };
}
tsx

Worth 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;
}
tsx

The 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;
}
tsx

This 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 ref
tsx

The 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 this bug. Every hook captures the render it was created in. useState's functional updater, useRef for latest values, and the exhaustive-deps rule all exist to manage a hazard the model creates.
  • useEffect is 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 return above a hook is a legal-looking edit that breaks the component. Only the lint rule catches it.
  • Memoisation is easy to cargo-cult. useMemo and useCallback everywhere 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 getServerSnapshot argument used during server rendering and hydration.
  • useReducer - reducer shape and when it beats multiple state variables.

Related Posts

19 min read
We measured client-side navigation on a deployed Next.js 16.1 site: a soft navigation transfers about half the bytes of a full page load, and a <Link> prefetch transfers the same payload as the navigation it prepares - the whole route, once per link in the viewport, in production. Then we read the useRouter return type out of the installed package: six methods, and no router.events, router.query, router.pathname, router.isReady, router.beforePopState or shallow routing.
By NoWaterProgramming Team
13 min read
The App Router explained through a site that actually runs on it: server components, async request APIs, generateStaticParams, and the three things that break when you deploy somewhere other than Vercel.
By NoWaterProgramming Team