close
Skip to main content

Command Palette

Search for a command to run...

React Hooks Masterclass

useState, useEffect, and Custom Hooks Explained

Updated
18 min readView as Markdown
React Hooks Masterclass
S
Software Developer | Full Stack Developer |

How Does React Remember Information Between Renders?

Here's a question that sounds simple but trips up most people learning React: when a component function runs top to bottom every time it renders, how does it "remember" anything? A regular JavaScript function forgets its local variables the moment it returns. So when you click a button and a counter goes from 3 to 4, where does that 3 live in between renders?

The answer is hooks. Hooks are React's mechanism for giving a function component memory (state) and a way to reach outside itself to talk to the world (effects). Once that clicks, almost everything else about hooks becomes a natural consequence of that one idea.

Why React Hooks Were Introduced?

Before hooks (pre-2019), React components came in two flavors:

  • Function components — simple, but stateless. They could only render props; they couldn't hold their own data or react to lifecycle events.

  • Class components — could hold state (this.state) and tap into lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount), but came with real costs.

Problems with the class-based pattern

  • this confusion. Every method needed to be bound, or you'd reach for arrow-function class properties just to avoid this being undefined.

  • Scattered logic. Related logic (say, subscribing to a WebSocket) had to be split across componentDidMount (subscribe) and componentWillUnmount (unsubscribe), even though it's conceptually one piece of behavior. Meanwhile, unrelated logic piled up together inside the same lifecycle method.

  • No easy way to reuse stateful logic. If two components both needed "track window width" logic, your only real options were higher-order components (HOCs) or render props — both of which wrap your component tree in extra layers, making the component tree in React DevTools look like an onion, and making the code harder to trace.

  • Large, hard-to-split components. As a class component grew, it became one giant blob. There was no lightweight way to extract a slice of behavior without extracting an entire component.

What hooks solved

  • Reusing logic between components — a custom hook lets you extract stateful logic into a plain function, no wrapping, no extra component layers.

  • Simpler component development — function components stay function components; you add capabilities by calling hooks, not by rewriting into a class.

  • Grouping by concern, not by lifecycle — code that belongs together (setup + cleanup for one feature) can live together in one useEffect, instead of being split across componentDidMount and componentWillUnmount.

  • Modern React development — hooks are now the default way to write React. Frameworks like Next.js, tooling like React DevTools, and most of the ecosystem (React Query, Zustand, Framer Motion) are designed hook-first.

The underlying shift: React moved from "components are classes with lifecycle methods" to "components are functions with attached, composable capabilities."

Understanding useState

useState is the hook that gives a function component memory. It returns a pair: the current value, and a function to update it.

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}
  • useState(0)0 is the initial value, used only on the very first render.

  • count — the current state value for this render.

  • setCount — the function you call to request a new value and trigger a re-render.

Updating state

State updates are requests, not instant mutations. Calling setCount(count + 1) doesn't change count in the current render — it tells React "next time you render this component, use this new value."

A common trap:

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

You might expect count to jump by 3, but it only jumps by 1. Each call captures the same count from this render's closure. The fix is the updater function form, which receives the latest pending value:

function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
}

Rule of thumb: if your new state depends on the previous state, use the updater function form.

Multiple state variables

You're not limited to one useState call. Most components use several, each responsible for one independent piece of data:

function UserProfile() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [isEditing, setIsEditing] = useState(false);

  // ...
}

Compare that to cramming everything into one object:

const [profile, setProfile] = useState({ name: "", email: "", isEditing: false });

This works, but there's a catch: unlike class components' this.setState, useState's setter replaces the state rather than merging it. So setProfile({ isEditing: true }) would wipe out name and email. You'd need to manually spread the previous object:

setProfile((prev) => ({ ...prev, isEditing: true }));

Pattern: use separate useState calls for independent values; group values into one state object only when they truly change together (e.g., { x, y } coordinates).

State-driven UI updates

The core idea of React is: UI is a function of state. You don't imperatively say "hide this div, show that div." You describe what the UI should look like for a given state value, and React figures out the DOM changes.

function ThemeSwitcher() {
  const [theme, setTheme] = useState("light");

  return (
    <div className={theme === "dark" ? "app-dark" : "app-light"}>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        Switch to {theme === "light" ? "dark" : "light"} mode
      </button>
    </div>
  );
}

There's no manual DOM manipulation here — theme changes, the component re-renders, and the returned JSX simply looks different for the new state.

Common state management patterns

  • Toggle state: const [isOpen, setIsOpen] = useState(false) with setIsOpen((v) => !v).

  • Form fields: one useState per input, or a single object for a whole form.

  • Derived values — don't store them. If a value can be calculated from existing state, don't put it in its own useState. Compute it during render instead (more on this below).

  • Lazy initial state: if computing the initial value is expensive, pass a function instead of a value: useState(() => expensiveComputation()). This runs only once, not on every render.

Understanding React Re-renders

This is the piece that ties everything together, so it's worth slowing down here.

What triggers a re-render?

A component re-renders when:

  1. Its state changes (a useState setter is called with a new value).

  2. Its parent re-renders (by default, children re-render too, even if their own props didn't change).

  3. Context it subscribes to changes (useContext).

Notably, props changing isn't a separate trigger — it's really a consequence of #2: the parent re-rendered and passed new props down.

State updates and rendering:

When you call a state setter:

  1. React schedules a re-render (it doesn't happen synchronously mid-function).

  2. React calls your component function again, top to bottom.

  3. Inside that new call, useState returns the new value.

  4. React compares the newly returned JSX against the previous render's output.

  5. React updates only the parts of the real DOM that actually changed.

How React updates the UI?

That comparison step is the "virtual DOM diffing" you may have heard about. Your component function returns a lightweight description of the UI (JSX → React.createElement calls), and React diffs that description against the previous one to compute the minimal set of real DOM operations. This is why React re-renders are relatively cheap — re-rendering a component doesn't mean "redraw everything on screen," it means "recompute the description and patch only the differences."

Avoiding unnecessary state

A frequent beginner mistake is storing something in state that could just be computed during render:

// Unnecessary state — fullName can drift out of sync
const [firstName, setFirstName] = useState("Ada");
const [lastName, setLastName] = useState("Lovelace");
const [fullName, setFullName] = useState("Ada Lovelace");
// Better — derive it, don't store it
const [firstName, setFirstName] = useState("Ada");
const [lastName, setLastName] = useState("Lovelace");
const fullName = `${firstName} ${lastName}`;

Rule of thumb: if you can calculate a value from other state or props during render, it doesn't need its own useState. Extra state means extra ways for your UI to become inconsistent.

Understanding useEffect

Why useEffect exists?

Rendering should be a pure calculation: given state and props, produce JSX. But real applications need to do things that aren't part of rendering — call an API, set up a subscription, manually interact with a browser API. These are side effects, and useEffect is React's designated place to run them, after the render has been committed to the screen.

import { useEffect } from "react";

useEffect(() => {
  // side effect code
});

Side effects in applications:

Typical examples of side effects:

  • Fetching data from a server

  • Subscribing to a WebSocket or event emitter

  • Reading or writing to localStorage

  • Manually manipulating the DOM (measuring an element, focusing an input)

  • Setting up timers or intervals

  • Logging or analytics

Data fetching:

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

  useEffect(() => {
    let ignore = false;

    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) setUser(data);
      });

    return () => {
      ignore = true; // avoid setting state from a stale request
    };
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <h2>{user.name}</h2>;
}

The ignore flag matters: if userId changes quickly (e.g., navigating between profiles), an older fetch could resolve after a newer one and overwrite fresh data with stale data. The cleanup function prevents that.

Event subscriptions:

useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

Timers and intervals:

useEffect(() => {
  const id = setInterval(() => {
    console.log("tick");
  }, 1000);

  return () => clearInterval(id);
}, []);

Cleanup functions:

If a useEffect sets something up, it usually needs to tear it down — otherwise you get memory leaks, duplicate subscriptions, or timers that keep firing after a component is gone. The function you return from inside useEffect is that teardown, and React calls it:

  • right before the effect runs again (if dependencies changed), and

  • when the component unmounts.

useEffect(() => {
  const controller = new AbortController();

  fetch("/api/data", { signal: controller.signal });

  return () => controller.abort(); // cleanup
}, []);

Dependency Arrays Explained

What dependency arrays are?

The second argument to useEffect is an array of values. It tells React: "only re-run this effect if one of these values has changed since the last render."

useEffect(() => {
  // effect body
}, [dependency1, dependency2]);

Running effects once:

An empty array means "this effect doesn't depend on anything from the render — run it only after the first render, never again":

useEffect(() => {
  console.log("Component mounted");
}, []);

Running effects on changes:

Including a value means the effect re-runs whenever that value changes between renders:

useEffect(() => {
  document.title = `${count} new messages`;
}, [count]);

No dependency array at all:

Omitting the array entirely means the effect runs after every single render — rarely what you want, but occasionally useful for debugging.

Dependency array When the effect runs
(omitted) After every render
[] Only after the first render
[a, b] After the first render, and whenever a or b changes

Common dependency mistakes:

  • Omitting a value you actually use inside the effect. If your effect reads userId but [] is the dependency array, the effect captures the first render's userId forever — a stale closure bug.

  • Passing an object or function created fresh each render. useEffect(() => {...}, [options]) where options = { limit: 10 } is a new object literal every render — the effect will re-run every time, defeating the purpose of the dependency array.

  • Fighting the linter instead of listening to it. The eslint-plugin-react-hooks exhaustive-deps rule usually points at a genuine bug. Suppressing the warning is rarely the right fix — restructuring the effect usually is.

Avoiding infinite loops:

A classic beginner bug:

const [data, setData] = useState([]);

useEffect(() => {
  setData([...data, "item"]); // depends on `data`, and changes `data`
}, [data]); // re-runs every time data changes → infinite loop

Fixes:

  • Use the updater form so the effect doesn't need data as a dependency: setData((prev) => [...prev, "item"]), and drop data from the array.

  • Or, more often, ask whether this really needs to be an effect at all — many "effects" that just recompute a value from existing state should instead be plain calculations during render.

Common useEffect Patterns

1. Fetching data from APIs:

The canonical pattern: fetch when a dependency (like an ID or search query) changes, guard against stale responses, and clean up.

useEffect(() => {
  let cancelled = false;

  async function loadResults() {
    const res = await fetch(`/api/search?q=${query}`);
    const json = await res.json();
    if (!cancelled) setResults(json);
  }

  loadResults();
  return () => {
    cancelled = true;
  };
}, [query]);

2. Listening to browser events:

Window resize, scroll position, online/offline status, keyboard shortcuts — all follow the subscribe-in-effect, unsubscribe-in-cleanup shape shown earlier.

3. Synchronizing external systems:

This is arguably the best mental model for useEffect: it's not "component lifecycle code," it's synchronization. You're keeping something outside React (the DOM title, a chat server connection, a video player's play state) in sync with something inside React (props and state).

useEffect(() => {
  const connection = createChatConnection(roomId);
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);

Whenever roomId changes, React disconnects the old connection and connects a new one — the external system stays synchronized with React's current state.

Cleanup best practices:

  • Always clean up anything that outlives a single render: subscriptions, timers, network requests, manual DOM listeners.

  • Cleanup functions should undo exactly what the effect set up — nothing more, nothing less.

  • If an effect doesn't set up anything persistent (e.g., a one-off console.log), it doesn't need a cleanup function at all.

Lifecycle thinking in React

It's tempting to map useEffect onto the old class lifecycle: "[] is componentDidMount, cleanup is componentWillUnmount." That mapping isn't wrong, but it's the less useful way to think about it. Lifecycle thinking asks "when does this run?" Synchronization thinking asks "what does this keep in sync, and with what?" The second question leads to fewer bugs, because it forces you to name every dependency the synchronization actually relies on.

Custom Hooks

What are custom hooks?

A custom hook is just a JavaScript function whose name starts with use and that calls other hooks inside it. That's the entire definition — there's no special API for "creating" a hook.

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return width;
}

Using it:

function Sidebar() {
  const width = useWindowWidth();
  return <p>Window is {width}px wide</p>;
}

Why custom hooks exist?

Before hooks, sharing this "track window width" logic across Sidebar and, say, Header, meant wrapping both in a higher-order component or render-prop component. Custom hooks let you extract the logic without extracting or wrapping any component. Each component that calls useWindowWidth() gets its own independent state — the hook is reusable logic, not shared state.

Reusing stateful logic:

Custom hooks can bundle useState and useEffect together into a reusable named concept:

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}
const [theme, setTheme] = useLocalStorage("theme", "light");

From the outside, useLocalStorage looks and behaves just like useState — but it also persists.

Separating concerns:

Custom hooks let a component's rendering logic stay clean while its stateful logic lives in a well-named, testable function elsewhere:

function ProfilePage({ userId }) {
  const { user, loading, error } = useUser(userId);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Something went wrong.</p>;
  return <h1>{user.name}</h1>;
}

ProfilePage no longer needs to know how user data is fetched, cached, or retried — that complexity lives inside useUser.

Building reusable abstractions:

Good custom hooks read like a small vocabulary for your app's domain: useAuth(), useCart(), useDebouncedValue(), useMediaQuery(). Each hides its internal useState/useEffect machinery behind a clear name and a small, predictable return value.

When to Create Custom Hooks?

You don't need a custom hook for every two lines of logic. Reach for one when you notice:

  1. Shared business logic: Two or more components need the exact same stateful behavior (form validation rules, permission checks, cart totals).

  2. Data fetching logic: Any time you find yourself copy-pasting a useState + useEffect fetch pattern between components, that's a strong signal to extract useFetch(url) or a resource-specific hook like useUser(id).

  3. Authentication logic: Checking whether a user is logged in, exposing login/logout functions, and reading the current user typically belongs in a useAuth() hook (often built on top of context).

function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within AuthProvider");
  return context;
}
  1. Form management: Tracking field values, validation errors, and submission state is repetitive enough across forms that a useForm() hook (or a library built on the same idea, like React Hook Form) pays for itself quickly.

  2. Window and device utilities: Window size, scroll position, online status, dark-mode preference, media query matches — all small, self-contained pieces of browser state that are natural custom-hook candidates:

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    function goOnline() { setIsOnline(true); }
    function goOffline() { setIsOnline(false); }
    window.addEventListener("online", goOnline);
    window.addEventListener("offline", goOffline);
    return () => {
      window.removeEventListener("online", goOnline);
      window.removeEventListener("offline", goOffline);
    };
  }, []);

  return isOnline;
}

Hooks Rules and Best Practices

Rules of hooks:

  • Only call hooks at the top level. Never inside loops, conditions, or nested functions.

  • Only call hooks from React functions. Function components or other custom hooks — not regular JavaScript functions.

// Wrong
if (isLoggedIn) {
  const [user, setUser] = useState(null);
}

// Right
const [user, setUser] = useState(null);
if (isLoggedIn) {
  // use `user` here
}

Predictable execution:

These rules exist because React tracks hooks by call order, not by name. On every render, React expects the first useState call to correspond to the same piece of state as the first useState call in the previous render, the second to match the second, and so on. If a hook call is skipped conditionally, every hook after it shifts by one — and React attaches the wrong state to the wrong hook.

Organizing hooks:

  • Group related useState declarations near the top of the component.

  • Keep each useEffect focused on one synchronization concern; don't cram unrelated side effects into a single effect just to "save a useEffect call."

  • Extract a hook as soon as logic needs to be reused, or as soon as a component's top feels cluttered with unrelated state and effects.

Avoiding common mistakes:

  • Don't call state setters directly during render (outside event handlers or effects) — that causes render loops.

  • Don't forget dependency arrays are about values read inside the effect, not about "when do I want this to run."

  • Don't overuse useEffect for things that are really just derived values or event-handler logic — not everything needs to be an effect.

Building maintainable components:

A useful gut check: if you can't explain what a useEffect synchronizes and with what, in one sentence, it likely needs to be split or rethought. Small, well-named hooks — built-in or custom — keep components readable months later.

Summary

  • useState gives function components memory across renders.

  • Re-renders happen when state changes, and React recomputes the JSX description, diffing it against the previous render to patch the real DOM efficiently.

  • useEffect synchronizes your component with the outside world — after render, not during it.

  • Dependency arrays tell React exactly when that synchronization needs to re-run.

  • Custom hooks package reusable stateful logic into small, well-named functions — no wrapping, no extra component layers.

Master these five ideas, and the rest of the hooks API (useReducer, useContext, useMemo, useCallback, useRef) will feel like natural extensions of the same core model, rather than a new set of rules to memorize.