React

Hooks — useState & useEffect

15 Questions

useState is the fundamental hook for declaring a state variable in a functional component. It takes an initial value and returns an array with two elements: the current state value and a setter function to update it. When you call the setter, React schedules a re-render and the component re-executes with the new state value. Convention is to destructure the array immediately: const [value, setValue] = useState(initialValue).
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
The argument to useState is the initial value. The setter function triggers a re-render with the new value.

Why it matters: useState is how React components remember things between renders. Without it, your UI would reset every time it updates.

Real applications: Toggling a menu open/closed, storing a user's input in a text field, tracking a counter.

Common mistakes: Forgetting that setState is asynchronous — reading the new value right after calling the setter won't give you the updated result.

When state holds an object or array, you must always create a new reference when updating — never mutate the existing value in place. React uses a shallow reference comparison to detect state changes: if the reference is the same object, React assumes nothing changed and skips the re-render. For objects, use the spread operator to copy all existing fields and override just what changed: { ...prev, name: 'new' }. For arrays, use methods that return a new array like map, filter, or spread rather than mutating methods like push or splice.
const [user, setUser] = useState({ name: "Alice", age: 25 });
// Update object
setUser(prev => ({ ...prev, age: 26 }));

const [items, setItems] = useState(["a", "b"]);
// Add to array
setItems(prev => [...prev, "c"]);
// Remove from array
setItems(prev => prev.filter(item => item !== "a"));
Using the spread operator or array methods like filter and map ensures immutability and proper re-rendering.

Why it matters: React only re-renders when it sees a new reference. Mutating the existing object means React won't notice the change and the UI won't update.

Real applications: Updating a to-do list, changing one field in a form object, adding or removing an item from a cart.

Common mistakes: Directly mutating state like state.items.push(...) instead of creating a new array with the spread operator.

useEffect lets you run side effects in functional components — operations that reach outside the component like data fetching, subscriptions, timers, or direct DOM manipulation. It runs after the component renders, keeping side effects out of the render phase to maintain React's predictable model. You can optionally return a cleanup function that runs before the effect re-fires and when the component unmounts. The dependency array controls when the effect re-runs — every render with no array, only on mount with an empty array, or when specific values change.
useEffect(() => {
  document.title = `Count: ${count}`;
}, [count]); // runs when count changes
By default, effects run after every render. The dependency array controls when the effect re-runs. It replaces lifecycle methods like componentDidMount and componentDidUpdate.

Why it matters: useEffect is how you run code after the UI has rendered — like fetching data, updating the browser title, or subscribing to events.

Real applications: Loading data from an API on page load, updating the tab title, setting up a WebSocket connection.

Common mistakes: Forgetting the dependency array so the effect runs after every single render when it only needs to run once.

The dependency array is the second argument to useEffect and controls exactly when the effect re-runs. With no array, the effect runs after every render. With an empty array [], it runs only once after initial mount. With specific values listed, it re-runs only when one of those values changes between renders. You should list every reactive value used inside the effect — state, props, functions — as a dependency; omitting them causes stale closure bugs where the effect reads outdated values.
useEffect(() => { /* runs on every render */ });
useEffect(() => { /* runs once on mount */ }, []);
useEffect(() => { /* runs when a or b changes */ }, [a, b]);
React compares dependencies using Object.is. Missing dependencies can cause stale closures, while unnecessary ones cause excessive re-runs. Always include all values the effect reads.

Why it matters: The dependency array controls how often your effect runs. Wrong dependencies cause bugs that are hard to track down.

Real applications: Re-fetching data when a search term changes, updating a chart when new data arrives.

Common mistakes: Leaving out a variable the effect uses (gets a stale value) or including objects that change every render (triggers an infinite loop).

The cleanup function is an optional function you return from useEffect to undo or tear down whatever the effect set up. It runs at two moments: before the effect fires again (when dependencies change) and when the component unmounts. Without cleanup, you'll get memory leaks and bugs from stale subscriptions or callbacks that outlive the component. Common patterns include clearing timeouts, canceling fetches with AbortController, removing event listeners, and closing WebSocket connections.
useEffect(() => {
  const handler = (e) => console.log(e.key);
  window.addEventListener('keydown', handler);

  return () => {
    window.removeEventListener('keydown', handler); // cleanup
  };
}, []);
Cleanup prevents memory leaks from subscriptions, timers, or event listeners. It is essential for any effect that sets up ongoing processes.

Why it matters: Without cleanup, effects keep running after a component is removed — causing memory leaks or errors on unmounted components.

Real applications: Clearing a setInterval timer, unsubscribing from a data stream, removing a window event listener.

Common mistakes: Not returning a cleanup function when using timers or subscriptions, leading to memory leaks in long-running apps.

React has two Rules of Hooks that must always be followed. First, only call hooks at the top level — never inside loops, conditions, or nested functions, because React relies on a consistent call order across renders to associate the right state with each hook. Second, only call hooks inside React functions — functional components or custom hooks — never in plain JavaScript utilities. The eslint-plugin-react-hooks lint rule enforces both rules and will automatically flag violations.
// ✅ Correct — top level of component
function App() {
  const [count, setCount] = useState(0);
  useEffect(() => { /* ... */ }, []);
}

// ❌ Wrong — inside a condition
function App() {
  if (loggedIn) {
    const [user, setUser] = useState(null); // breaks hook order
  }
}
React relies on the call order of hooks to associate state with the correct hook. Conditional or nested hook calls break this mechanism.

Why it matters: React tracks hook calls by their order. Breaking the rules causes hooks to read the wrong state and leads to confusing bugs.

Real applications: Every React component and custom hook must follow these rules. The eslint-plugin-react-hooks linter enforces them automatically.

Common mistakes: Calling a hook inside an if statement or inside a regular helper function that isn't a component or custom hook.

An empty dependency array [] tells React that the effect has no reactive dependencies and should run only once, after the initial mount. This is the functional-component equivalent of the class lifecycle method componentDidMount and is the right choice for one-time operations like setting up a WebSocket, registering a global event listener, or fetching initial data. If you return a cleanup function alongside the empty array, that cleanup runs when the component unmounts — equivalent to componentWillUnmount.
useEffect(() => {
  console.log("Component mounted");
  fetchInitialData();

  return () => {
    console.log("Component will unmount");
  };
}, []); // empty array — runs once
The cleanup function will run when the component unmounts. This is the standard pattern for one-time initialization like API calls or setting up subscriptions.

Why it matters: Passing [] means "run this once when the component mounts." It's the most common pattern for one-time setup work.

Real applications: Fetching initial page data, registering a global keyboard shortcut, starting a background timer.

Common mistakes: Using variables inside the effect without adding them to the dependency array, creating a stale closure that always reads the first value.

Data fetching is one of the most common uses of useEffect. Because the effect callback cannot itself be async, the pattern is to define an async function inside the effect and call it immediately. Always include an AbortController cleanup to cancel in-flight requests when the component unmounts or dependencies change — otherwise, a response arriving after unmount will try to update state on an unmounted component. Listing fetch-triggering values (like userId) in the dependency array ensures data re-fetches automatically when those values change.
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false;
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => { if (!cancelled) setUser(data); });
    return () => { cancelled = true; };
  }, [userId]);

  return user ? <p>{user.name}</p> : <p>Loading...</p>;
}
The cancelled flag prevents setting state on an unmounted component, avoiding race conditions and memory leaks.

Why it matters: Data fetching is one of the most common things in React apps. Doing it wrong leads to race conditions or errors on unmounted components.

Real applications: Loading a user profile, fetching product listings, getting data for a dashboard widget.

Common mistakes: Not handling the case where the component unmounts before the fetch finishes, causing a "Can't perform state update on unmounted component" warning.

You can call useState as many times as needed to manage separate, independent pieces of state. Each call creates its own isolated state slot with its own current value and setter function. React keeps them separate by tracking the order in which hooks are called on each render — this is why hooks must always be called in the same order and cannot be placed inside conditionals. Prefer multiple small state variables over one large object when the individual values update independently, to avoid unnecessary merging logic.
function Form() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [age, setAge] = useState(0);

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <input type="number" value={age} onChange={e => setAge(+e.target.value)} />
    </form>
  );
}
Splitting state into multiple variables keeps updates granular. Group related values into an object only when they always change together.

Why it matters: Separate state variables update independently, so changing one doesn't force the rest of your component to re-render unnecessarily.

Real applications: Tracking loading, error, and data as three separate pieces of state in a fetch request; managing individual form fields.

Common mistakes: Putting unrelated data into one state object and updating it all at once, even when only one field changed.

A stale closure bug happens when a function inside a hook (like a timer callback or event handler) captures an old snapshot of state or props from the render when it was created, and that snapshot no longer reflects the current value. This is most common inside useEffect with an empty or incomplete dependency array. The fixes are: add the missing value to the dependency array, use the functional updater form setState(prev => ...) so the callback doesn't need to read the value, or store the value in a ref so the callback always reads the latest version.
// Bug: count is always 0 inside the interval
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // stale closure — count is always 0
  }, 1000);
  return () => clearInterval(id);
}, []);

// Fix: use the functional updater
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // always uses latest value
  }, 1000);
  return () => clearInterval(id);
}, []);
Using the functional updater form of the setter avoids stale closures by referencing the previous state directly.

Why it matters: Stale closures are a common source of subtle bugs where your effect or handler uses an old value instead of the current one.

Real applications: Click handlers that read state, effects that check a timer, any callback that closes over a state value.

Common mistakes: Assuming the state value inside a setInterval callback is always current — it captures the value at the time the effect ran.

useLayoutEffect has the same signature as useEffect but fires synchronously after DOM mutations and before the browser paints the screen. This makes it the right choice when you need to read a DOM measurement (like an element's size or position) or apply a DOM update before the user sees anything, preventing visible flicker. Because it blocks painting, heavy work inside it can delay the frame and hurt responsiveness — use it only when useEffect produces a visible flash. On the server (SSR), useLayoutEffect does not run, so SSR-compatible code should use useEffect instead.
import { useLayoutEffect, useRef, useState } from 'react';

function Tooltip() {
  const ref = useRef();
  const [width, setWidth] = useState(0);

  useLayoutEffect(() => {
    // Read DOM size before browser paints — no flicker
    setWidth(ref.current.getBoundingClientRect().width);
  }, []);

  return <div ref={ref}>Width: {width}px</div>;
}
Prefer useEffect for most side effects. Use useLayoutEffect only when you observe a visible flicker caused by post-paint DOM reads.

Why it matters: useLayoutEffect fires before the browser paints the screen, letting you make DOM changes that won't cause a visible flash.

Real applications: Measuring a DOM element's size before the first paint, animating an element that needs correct dimensions right away.

Common mistakes: Using useLayoutEffect when useEffect would work fine — it blocks painting and can slow down the app.

A useEffect infinite loop occurs when an effect updates a piece of state that is listed in its own dependency array, creating a cycle: render → effect → state update → re-render → effect → …. The most common cause is updating state unconditionally inside an effect and including that state as a dependency. The fix is to either guard the update conditionally, remove the state from the dependency array if the effect doesn't actually need to react to it, or restructure the logic. Another common cause is passing an unstabilized function or object as a dependency that creates a new reference on every render.
// ❌ Infinite loop — effect sets count, count is a dependency
useEffect(() => {
  setCount(count + 1); // triggers re-render → re-runs effect → repeat
}, [count]);

// ✅ Fix — use functional updater, dependency not needed
useEffect(() => {
  setCount(prev => prev + 1);
}, []); // runs only once

// ✅ Fix — depend on an external trigger, not the value being set
useEffect(() => {
  if (shouldRefetch) fetchData();
}, [shouldRefetch]);
Always ask: does the effect change the same value it depends on? If yes, break the cycle with a functional updater or a different dependency.

Why it matters: An infinite loop crashes your app and floods the server with requests. It's one of the most common beginner mistakes with useEffect.

Real applications: Any effect that updates state or calls an async function needs careful dependency management to avoid triggering itself.

Common mistakes: Setting state inside an effect that depends on that same state without a condition or a functional update pattern.

Debouncing with useEffect works by scheduling work with setTimeout and canceling the previous timer in the cleanup function — so rapid changes only trigger the actual work once, after the user stops changing the value. Each time the dependency changes, the cleanup runs first and clears the pending timeout, then a new one is set. This is the standard pattern for search-as-you-type: wait until the user finishes typing before sending the API request instead of firing on every keystroke. The delay (typically 300–500ms) is adjustable to balance responsiveness and request frequency.
function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) return;

    // Delays the API call by 400ms after the last keystroke
    const timer = setTimeout(async () => {
      const data = await searchAPI(query);
      setResults(data);
    }, 400);

    // Cleanup cancels the timer on each new keystroke
    return () => clearTimeout(timer);
  }, [query]);
}
The cleanup cancels the previous timer on every keystroke. Only the final timer (after the user pauses) fires the actual request.

Why it matters: Without debouncing, you'd fire an API call on every single keypress, wasting bandwidth and overloading the server.

Real applications: Search-as-you-type, live filtering a data table, auto-saving form input after the user stops typing.

Common mistakes: Forgetting to clear the timer in the cleanup function, letting old timers fire even after the user has typed more.

You can and should call useEffect multiple times in a single component — each effect should handle one concern and be split by what triggers it, not by lifecycle phase. For example, one effect fetches user data when userId changes, and a separate effect subscribes to a WebSocket when roomId changes. Keeping concerns separate makes each effect easier to understand, test, and clean up independently. Mixing unrelated side effects into one effect just because they all run on mount leads to tangled logic that is hard to maintain.
function Dashboard({ userId, roomId }) {
  // Effect 1 — fetch user when userId changes
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  // Effect 2 — connect to chat when roomId changes
  useEffect(() => {
    const socket = connectChat(roomId);
    return () => socket.disconnect();
  }, [roomId]);

  // Effect 3 — sync document title
  useEffect(() => {
    document.title = `User ${userId}`;
  }, [userId]);
}
Separating effects by concern makes each one easier to read, test, and reason about independently.

Why it matters: Grouping related logic together makes components easier to read and avoids one giant effect that does too many unrelated things.

Real applications: One effect for data fetching, another for analytics tracking, a third for subscribing to a socket — all in the same component.

Common mistakes: Combining multiple unrelated concerns in one useEffect, making it hard to see what each part depends on or why it runs.

The hooks equivalent of componentDidMount is useEffect with an empty dependency array [] — the effect fires exactly once after the first render. Returning a cleanup function from that same effect is the equivalent of componentWillUnmount — it runs when the component is removed from the tree. useEffect with a non-empty dependency array covers componentDidUpdate — it fires after any render where the listed dependencies changed. These three patterns together replace the three most commonly used class lifecycle methods.
function Analytics() {
  useEffect(() => {
    // Runs once on mount — good for one-time setup
    trackPageView(window.location.href);
    const socket = connectSocket();

    return () => {
      // Cleanup runs on unmount
      socket.disconnect();
    };
  }, []); // empty array = run once
}
Common use cases: initial data fetching, starting a WebSocket, registering a global event listener, or running analytics. The cleanup function runs when the component unmounts.

Why it matters: Some setup (like connecting to a server or registering a listener) should only happen once, not on every re-render.

Real applications: Fetching user data on login, setting up a third-party SDK like Google Analytics, subscribing to a WebSocket on mount.

Common mistakes: Adding state variables to the dependency array that should be empty, causing the "once" effect to re-run every time state changes.