← React, from components to interview-ready

Lesson 3 of 5

Hooks deep dive: useEffect, useRef, useMemo, useCallback, useContext and custom hooks

5 min read

Hooks let function components use React features. Two rules: call hooks only at the top level (not inside loops, conditions or nested functions) and only from React functions (components or custom hooks). React relies on the call order being the same on every render to know which state belongs to which hook.

useEffect: synchronising with the outside world

An effect runs after React commits to the screen. Use it to sync with something outside React: network requests, subscriptions, timers, the document title, third-party widgets.

jsx
useEffect(() => {
  // setup
  return () => {
    // cleanup: runs before the next effect and on unmount
  };
}, [dependencies]);
Dependency arrayWhen it runs
noneafter every render
[]once after the first render (and cleanup on unmount)
[a, b]after the first render and whenever a or b changes

Fetching data correctly

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

  useEffect(() => {
    const controller = new AbortController();
    setUser(null);
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.statusText))))
      .then(setUser)
      .catch((e) => { if (e.name !== "AbortError") setError(e); });
    return () => controller.abort(); // userId changed or unmounted: drop the old request
  }, [userId]);

  if (error) return <p>Could not load the profile.</p>;
  if (!user) return <Spinner />;
  return <h2>{user.name}</h2>;
}

The cleanup prevents a race condition: if userId changes quickly, an older slow response can't overwrite the newer one. In real apps, a data library (TanStack Query, SWR) or a framework loader (Next.js server components) handles caching, retries and deduplication for you; say this in interviews to show you know the trade-off.

You might not need an effect

Effects are for external systems. Common misuse:

  • Computing derived data (useEffect(() => setTotal(sum(items)), [items])): just compute const total = sum(items) during render.
  • 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: use a key instead.

Strict mode double-run

In development, React 18 Strict Mode mounts, unmounts and re-mounts components, so effects run twice. It's deliberate: it surfaces effects that are missing cleanup. It doesn't happen in production.

useRef: a box that survives renders

useRef returns { current: value }. Changing .current does not cause a re-render. Two uses:

jsx
// 1. Access a DOM node
const inputRef = useRef(null);
<input ref={inputRef} />;
inputRef.current.focus();

// 2. Keep a mutable value between renders (timer ids, previous values)
const timer = useRef(null);
function start() { timer.current = setInterval(tick, 1000); }
function stop() { clearInterval(timer.current); }

Rule of thumb: if the screen should update when it changes, it's state; if not, it's a ref.

useMemo and useCallback

  • useMemo(() => compute(a, b), [a, b]) caches a value between renders.
  • useCallback(fn, [deps]) caches a function (it's useMemo(() => fn, deps)).

They matter in two situations: an expensive calculation, or keeping a stable reference for a child wrapped in React.memo or for another hook's dependency array. Wrapping everything adds noise and its own cost. Memoize when you have a reason. (The React Compiler can add memoization automatically in newer setups.)

useContext: avoiding prop drilling

jsx
const ThemeContext = createContext("light");

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

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

Context is great for values many components read and that change rarely: theme, logged-in user, language. Every consumer re-renders when the value changes, so for fast-changing global state use a store (Zustand, Redux Toolkit) or split contexts.

useReducer: state with rules

When state has several related fields and many ways to change, a reducer keeps the logic in one place and makes it testable:

jsx
function reducer(state, action) {
  switch (action.type) {
    case "added": return [...state, { id: action.id, text: action.text, done: false }];
    case "toggled": return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "deleted": return state.filter((t) => t.id !== action.id);
    default: throw new Error(`Unknown action ${action.type}`);
  }
}
const [todos, dispatch] = useReducer(reducer, []);
dispatch({ type: "added", id: crypto.randomUUID(), text: "Practise hooks" });

Custom hooks: reuse logic, not UI

A custom hook is a function starting with use that calls other hooks. Each component using it gets its own state.

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

function Search() {
  const [q, setQ] = useState("");
  const query = useDebounce(q, 400);
  const results = useSearch(query); // only fires 400ms after typing stops
  ...
}

Popular custom hooks to be able to write on a whiteboard: useDebounce, useLocalStorage, useFetch, useOnClickOutside, usePrevious, useWindowSize.

Check yourself

  • What runs first: the effect's cleanup or the next effect?
  • Why can't hooks be called inside an if?
  • When is useCallback actually useful?
  • Write useLocalStorage(key, initial) from memory.

Sign up free to save your progress.