Learn Frontend Part 11: React Hooks - useEffect, useRef, useMemo, useCallback and Custom Hooks

Learn Frontend Part 11: React Hooks - CodingHumans

Hooks are how function components get state, side effects, refs and context. They are also the single most common topic in React interviews: "why does my effect run twice?", "what goes in the dependency array?", "when should I use useMemo?" and "write a custom hook" come up again and again.

This is Part 11 of the Learn Frontend series, following React Fundamentals. We cover the rules of hooks, useState and useReducer, useEffect and its cleanup, useRef, useMemo, useCallback and React.memo, useContext, custom hooks, and the newer React 18 and 19 hooks.

1. The rules of hooks

Hooks are functions whose names start with use. React tracks them by call order: the first useState call in a component is slot 1, the second is slot 2, and so on. That is why there are two rules:

  1. Only call hooks at the top level. Never inside conditions, loops, nested functions or after an early return. If the call order changes between renders, React gives state to the wrong hook.
  2. Only call hooks from React functions: function components or other custom hooks, not regular JavaScript functions or class components.

The eslint-plugin-react-hooks package enforces both rules and also checks dependency arrays. Keep it on. The one exception is React 19's use(), which can be called conditionally.

2. useState and useReducer

useState is covered in Part 10. Two extra details matter in interviews:

  • Lazy initial state: useState(() => expensiveParse(data)) runs the function only on the first render. useState(expensiveParse(data)) runs it on every render and throws the result away.
  • If you set state to the same value (checked with Object.is), React can bail out without re-rendering the children.

When several pieces of state change together, or the next state depends on an action type, useReducer keeps the logic in one pure function that is easy to test:

const initial = { items: [], status: "idle", error: null };

function cartReducer(state, action) {
  switch (action.type) {
    case "added":
      return { ...state, items: [...state.items, action.item] };
    case "removed":
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case "checkout_failed":
      return { ...state, status: "error", error: action.error };
    default:
      throw new Error("Unknown action: " + action.type);
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initial);
  return <button onClick={() => dispatch({ type: "added", item: { id: 1, name: "Book" } })}>Add</button>;
}

Rule of thumb: use useState for independent simple values and useReducer when updates follow clear "events" or touch several fields at once.

3. useEffect: synchronising with the outside world

useEffect runs code after React commits to the screen. Use it to synchronise a component with something outside React: a subscription, a timer, a WebSocket, a manual DOM API, or a network request.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let ignore = false;                 // guards against race conditions
    const controller = new AbortController();

    fetch("/api/users/" + userId, { signal: controller.signal })
      .then(res => res.json())
      .then(data => { if (!ignore) setUser(data); })
      .catch(() => {});

    return () => {                      // cleanup: runs before next effect and on unmount
      ignore = true;
      controller.abort();
    };
  }, [userId]);                         // re-run only when userId changes

  return user ? <h2>{user.name}</h2> : <p>Loading...</p>;
}

function Clock() {
  const [now, setNow] = useState(Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);     // without this, intervals pile up
  }, []);
  return <p>{new Date(now).toLocaleTimeString()}</p>;
}

The dependency array controls when it runs:

FormRuns
useEffect(fn)After every render
useEffect(fn, [])After the first render only (mount)
useEffect(fn, [a, b])After mount and whenever a or b changes (compared with Object.is)

Things that trip people up:

  • Cleanup runs before the effect runs again and when the component unmounts. Always clean up timers, listeners and subscriptions.
  • Strict Mode in development mounts, unmounts and mounts again, so effects run twice. This is on purpose, to reveal missing cleanup. It does not happen in production.
  • Race conditions: if the userId changes quickly, an older request can finish last. The ignore flag or an AbortController fixes this.
  • Stale closures: an effect sees the values from the render it was created in. Missing dependencies mean it reads old values. Do not silence the lint rule; fix the dependencies or use an updater function.
  • Objects and functions created during render are new every time, so putting them in dependencies makes the effect run on every render.
  • useLayoutEffect runs synchronously after DOM changes but before the browser paints. Use it only to measure layout and avoid a visible flicker.

4. You might not need an effect

Many effects are unnecessary and cause extra renders. Before writing one, ask whether the code is really syncing with an external system.

  • Derived data: compute it during render (const visible = todos.filter(...)) instead of storing it in state and syncing with an effect.
  • Responding to a user event: put the logic in the event handler, not in an effect that watches state.
  • Resetting state when a prop changes: give the component a key instead.
  • Fetching data: in real apps, prefer a data library (TanStack Query, SWR) or framework loaders and Server Components. Part 12 and Part 13 cover these.

5. useRef: values that do not trigger renders

useRef returns a mutable object { current } that stays the same across renders. Changing ref.current does not cause a re-render. It has two main uses:

function SearchBox() {
  const inputRef = useRef(null);       // DOM reference
  const renders = useRef(0);           // value that does not trigger renders

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  useEffect(() => {
    renders.current += 1;
  });

  return <input ref={inputRef} placeholder="Search" />;
}
  • Holding a DOM node to focus an input, scroll, measure size or integrate a non-React library.
  • Storing instance-like values that should survive renders but not affect output: timer ids, the previous value, an AbortController.
  • Do not read or write ref.current during render (except for lazy initialisation). It makes the output unpredictable.
  • In React 19, ref is a normal prop for function components, so forwardRef is no longer needed.

6. useMemo, useCallback and React.memo

These three are about skipping work, and they are often overused.

  • useMemo(fn, deps) caches the result of a calculation between renders.
  • useCallback(fn, deps) caches the function itself. It is the same as useMemo(() => fn, deps).
  • React.memo(Component) skips re-rendering a component when its props are shallowly equal to last time.
const Row = React.memo(function Row({ item, onSelect }) {
  return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});

function ProductList({ products, query }) {
  const [selected, setSelected] = useState(null);

  // Cache an expensive calculation
  const visible = useMemo(
    () => products.filter(p => p.name.toLowerCase().includes(query.toLowerCase())),
    [products, query]
  );

  // Stable function identity so memoised Row does not re-render
  const handleSelect = useCallback(id => setSelected(id), []);

  return <ul>{visible.map(p => <Row key={p.id} item={p} onSelect={handleSelect} />)}</ul>;
}

The important insight: useCallback on its own does nothing useful. It only helps when the function is passed to a memo-wrapped child or used as an effect dependency. Memoisation also has a cost (memory and comparisons), so measure with the React DevTools Profiler first. The React Compiler (stable in 2025) can add this memoisation automatically at build time, which makes manual useMemo and useCallback much less necessary in new projects.

7. useContext: sharing values without prop drilling

Context passes a value to any component below a provider, without threading props through every level. It is ideal for values that many components read and that change rarely: theme, locale, the current user.

const ThemeContext = createContext("light");

function App() {
  const [theme, setTheme] = useState("dark");
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return (
    <ThemeContext.Provider value={value}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function ThemeButton() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      Theme: {theme}
    </button>
  );
}

Every component that reads a context re-renders when the provider's value changes. If you pass a new object each render (value={{ user, setUser }}), all consumers re-render every time the provider renders. Wrap the value in useMemo, or split it into separate contexts. Context is a transport mechanism, not a state manager. Part 12 compares it with Redux and Zustand.

8. Custom hooks

A custom hook is a function starting with use that calls other hooks. It lets you reuse stateful logic, not state itself: every component that calls the hook gets its own independent copy.

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key);
      return saved !== null ? JSON.parse(saved) : initialValue;
    } catch {
      return initialValue;
    }
  });
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  return [value, setValue];
}

// Usage
function Search() {
  const [text, setText] = useState("");
  const query = useDebounce(text, 400);   // only changes 400 ms after typing stops
  // fetch results for query...
  return <input value={text} onChange={e => setText(e.target.value)} />;
}

Good custom hooks have a clear name and a small return value, such as useDebounce, useLocalStorage, useOnlineStatus, useMediaQuery or usePrevious. Writing one of these from scratch is a very common interview task.

9. Newer hooks in React 18 and 19

  • useId generates stable unique ids that match between server and client, for linking labels and inputs.
  • useTransition marks an update as non-urgent, so typing stays responsive while a slow list re-renders. It returns [isPending, startTransition].
  • useDeferredValue gives a lagging copy of a value, useful for search results that can update slightly later than the input.
  • useSyncExternalStore subscribes safely to external stores. Libraries like Redux and Zustand use it internally.
  • use() (React 19) reads a promise or context. With a promise, it suspends until the value resolves, working with Suspense.
  • useActionState and useFormStatus (React 19) manage form submission state with Actions.
  • useOptimistic (React 19) shows an optimistic result while an async action is in progress.
Key takeaways
  • React tracks hooks by call order, so call them only at the top level of components and custom hooks.
  • Use useReducer when several values change together or updates are driven by named actions.
  • useEffect is for syncing with external systems. Always clean up, list every dependency, and guard async work against races.
  • Derived values, event logic and state resets usually do not need an effect.
  • useRef holds a mutable value or DOM node without causing renders.
  • useCallback only helps with memo-wrapped children or effect dependencies. Measure before memoising, or let the React Compiler do it.
  • Context avoids prop drilling, but every consumer re-renders when the value changes. Memoise or split the value.
  • Custom hooks share logic, not state. Each caller gets its own copy.

Practice problems

These are common machine-coding and interview tasks. Write each hook from scratch, then test it in a small component.

  1. Write usePrevious(value) that returns the value from the previous render.
  2. Write useDebounce and use it to debounce an API search box.
  3. Write useFetch(url) that returns { data, error, loading } and handles race conditions with AbortController.
  4. Write useOnlineStatus using the browser online and offline events with proper cleanup.
  5. Build a theme switcher with Context that does not re-render consumers unnecessarily.
  6. Take a slow list and fix it with React.memo plus useCallback, then confirm with the React Profiler.
  7. Build a stopwatch with start, stop and reset using useRef for the interval id.

Interview questions and FAQ

Why does my useEffect run twice?

In development with Strict Mode, React mounts, unmounts and re-mounts every component once to check that your effects clean up correctly. It does not happen in production. The fix is a correct cleanup function, not removing Strict Mode.

What is the difference between useMemo and useCallback?

useMemo caches the value a function returns. useCallback caches the function itself. useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

What is a stale closure in React?

A function created during a render captures the props and state of that render. If an effect or callback is not recreated when those values change, usually because of a missing dependency, it keeps reading the old values. Fix it by listing the dependencies or by using the updater form of setState.

What is the difference between useEffect and useLayoutEffect?

useEffect runs after the browser paints, so it does not block the screen. useLayoutEffect runs after DOM changes but before paint, so it can measure layout and adjust without flicker, but it blocks painting. Prefer useEffect.

Can you call hooks inside a condition?

No. React matches hooks to their state by the order they are called, so the order must be the same on every render. Put the condition inside the hook instead. React 19's use() is the exception.

When should you use useReducer instead of useState?

When state has several related fields, when the next state depends on the previous one in complex ways, or when you want to describe updates as actions that are easy to test and log.

Comments

Answered 0/0Correct 0