Week 2: State & Effects — useState and useEffect

These two hooks show up in almost every component you'll ever write, and they're conceptually very different: useState is data your component owns and re-renders on; useEffect is how a component reaches outside itself — to the DOM, a timer, a subscription, or a network request. Confusing the two is where most early React bugs come from, so this week is entirely about building sharp intuition for both.

Module 2 of 17 Week 2 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Update state correctly, including when the new value depends on the old one
  • Write effects with correct dependency arrays and cleanup functions
  • Recognize and fix stale closures and effect-triggered infinite loops

1. useState in Depth

useState(initial) returns a pair: the current value, and a setter function that schedules a re-render with a new value. The part that's easy to miss from Week 1: state updates are asynchronous and can be batched — calling the setter doesn't change the variable immediately in the same line of code.

stale-read.tsx
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count); // still the OLD value -- setCount hasn't re-rendered yet
  }

  return <button onClick={handleClick}>{count}</button>;
}

This gets genuinely dangerous when you call the setter more than once based on the same stale count:

the bug
function handleClick() {
  setCount(count + 1); // count is 0 here -> sets to 1
  setCount(count + 1); // count is STILL 0 here -> sets to 1 again, not 2
}

The fix is the functional update form: pass a function to the setter, and React guarantees it receives the most recent state, even across multiple queued updates in the same event:

the fix
function handleClick() {
  setCount(prev => prev + 1); // 0 -> 1
  setCount(prev => prev + 1); // 1 -> 2, correctly
}
Rule of thumb

If your new state depends on the previous state in any way, use the functional form: setX(prev => ...). If it's a completely new value unrelated to the old one, setX(newValue) is perfectly fine.

State is also treated as immutable — never mutate an object or array in place and pass it back to the setter; React compares state by reference, so a mutated-in-place object looks identical to React and won't trigger a re-render.

immutable updates
const [user, setUser] = useState({ name: 'Ada', age: 30 });

// Wrong -- mutates the existing object, React sees the same reference
user.age = 31;
setUser(user);

// Right -- creates a new object, React sees a new reference and re-renders
setUser({ ...user, age: 31 });

const [items, setItems] = useState([1, 2, 3]);

// Wrong
items.push(4);
setItems(items);

// Right
setItems([...items, 4]);

2. useEffect: Synchronizing with the Outside World

Everything inside a component's function body should be about computing what to render. The moment you need to touch something outside React — the browser DOM directly, document.title, a timer, a WebSocket, or (starting Week 8) a network request — that's an effect.

DocumentTitle.tsx
import { useState, useEffect } from 'react';

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

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

  return <button onClick={() => setCount(c => c + 1)}>Click me</button>;
}

useEffect takes a function to run after React has committed the render to the DOM, plus a second argument — the dependency array — that controls when it re-runs. Here, the effect re-runs only when count changes, keeping the document title in sync without running on every unrelated render.

Ask "is this a side effect?" before reaching for useEffect

Computing a value from props/state (like a filtered list, or a total) belongs directly in the render body, not in an effect — see Week 3's useMemo for when that computation is expensive enough to matter. useEffect is specifically for synchronizing with something React doesn't manage.

3. Dependency Arrays — What They Actually Compare

The dependency array tells React: "re-run this effect only if one of these values changed since the last render." React compares each entry with Object.is — the same reference-equality check state updates use. Three shapes, three very different behaviors:

the three shapes
useEffect(() => {
  console.log('runs after EVERY render');
}); // no array at all

useEffect(() => {
  console.log('runs once, after the first render only');
}, []); // empty array -- nothing to depend on

useEffect(() => {
  console.log('runs after the first render, and again whenever userId changes');
}, [userId]); // runs when a listed value changes

A common trap: including an object or array literal created fresh on every render as a dependency. Because it's a new reference every time, the effect thinks something changed and re-runs on every single render — even if the object's contents look identical.

the trap
function SearchResults({ query }: { query: string }) {
  const options = { caseSensitive: false }; // NEW object every render

  useEffect(() => {
    search(query, options);
  }, [query, options]); // options never "equals" its previous value -> runs every render
}

The fix here is usually to depend only on the primitive values the effect actually reads (query, options.caseSensitive), or to move the object construction inside the effect itself where it isn't a dependency at all.

4. Cleanup Functions

If an effect sets something up — a timer, an event listener, a subscription — it needs to tear that thing down, or you'll leak it every time the effect re-runs or the component unmounts. Return a function from the effect, and React calls it right before the effect runs again, and once more when the component unmounts.

Timer.tsx
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(id); // cleanup -- runs on unmount
  }, []); // empty array -- set up the interval once

  return <p>{seconds}s elapsed</p>;
}

Without that clearInterval, navigating away from this component would leave the interval running forever in the background, still calling setSeconds on a component that no longer exists — exactly the kind of leak that's invisible in a five-minute demo and very visible in a real app after an hour of use.

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

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

5. Common Pitfalls: Stale Closures & Infinite Loops

A stale closure happens when an effect (or any function) captures a variable's value at the time it was created, and that value never gets refreshed because the effect wasn't told to re-run:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // always logs 0 -- this closure captured count=0 forever
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty array -- effect never re-runs, so `count` inside it never updates

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

The interval callback closed over count from the render where the effect first ran, and because the dependency array is empty, that effect (and its closure) is never recreated. Two fixes: add count to the dependency array (the interval resets every time count changes), or use the functional update form inside a ref-based pattern if you specifically need the interval to keep running uninterrupted — the first option is almost always what you want.

The other classic bug is the infinite loop: an effect that updates state it also depends on, without a condition that eventually stops it.

the bug
function BadCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // triggers a re-render...
  }, [count]); // ...which changes count...which reruns the effect...forever

  return <p>{count}</p>;
}

Every render sets state, every state change re-runs the effect, every effect run sets state again. React doesn't stop this for you — it'll happily re-render as fast as it can until the tab hangs. If an effect sets state, double check whether it truly needs to depend on that same state, or whether the update belongs somewhere else entirely (an event handler, or removed altogether if it can be computed directly during render).

6. useEffectEvent: Reading Fresh State Without Restarting the Effect

Section 5's fix for the interval's stale closure was to add count to the dependency array — correct, but it means the interval is torn down and recreated every single time count changes, resetting its timing. Sometimes that's fine. Sometimes you specifically need an effect that keeps running uninterrupted (connect once, start a timer once) while the code inside it still reads the latest props and state, not whatever they were when the effect first ran. That's what useEffectEvent (stable since React 19.2) is for.

Counter.tsx — interval that never restarts, but never goes stale either
import { useEffect, useEffectEvent, useState } from 'react';

function Counter({ label }: { label: string }) {
  const [count, setCount] = useState(0);

  const onTick = useEffectEvent(() => {
    console.log(`${label}: ${count}`); // always reads the LATEST label and count
  });

  useEffect(() => {
    const id = setInterval(() => {
      onTick();
    }, 1000);
    return () => clearInterval(id);
  }, []); // deliberately empty -- onTick is not "reactive," so it's not a dependency

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

A function created by useEffectEvent is called an Effect Event. It can only be called from inside an effect (or another Effect Event), it's deliberately excluded from dependency arrays — the hook's lint rule expects that and won't ask you to add it — and inside its body, every prop and piece of state always reflects the render that's currently visible on screen, not the render that was active when the effect last ran. The interval in the example above is created exactly once and never resets, yet onTick always logs the current count and label — the stale-closure bug from Section 5 is gone without paying the "restart the whole effect" cost.

An Effect Event isn't an effect

You can't call one during render, and it can't return a cleanup function — it's purely "code that runs in response to something the effect triggered, but reads fresh values." Think of the split as: the effect's dependency array answers "when should this whole thing re-synchronize?"; the Effect Event answers "what's the current value of things I only need to read, not react to?"

7. Hands-on Exercise

Hands-on

Build a stopwatch with lap times, then fix an injected race condition

Practice effects, cleanup, and functional state updates by building something that would break immediately if you got any of them wrong.

Part 1 — Stopwatch:

  1. Build a Stopwatch component with elapsedMs state, and Start/Pause/Reset buttons.
  2. Use setInterval inside a useEffect that only runs while the stopwatch is "running" (make the effect depend on a isRunning boolean), updating elapsedMs with the functional setter form every 10ms.
  3. Properly clean up the interval whenever isRunning flips to false or the component unmounts — no leaked intervals.
  4. Add a "Lap" button that appends the current elapsedMs to a laps: number[] state array, rendered as a list, most recent first.

Part 2 — Fix the race condition:

UserProfile.tsx — has a bug
function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<{ name: string } | null>(null);

  useEffect(() => {
    fetchUser(userId).then(data => setUser(data)); // BUG: no cleanup
  }, [userId]);

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

If userId changes quickly (e.g. clicking through a list fast), an older, slower request can resolve after a newer one and overwrite it with stale data. Add a cleanup-based "ignore" flag so a request only calls setUser if its effect is still the current one when it resolves.

Hint

Declare let ignore = false; at the top of the effect, set ignore = true in the cleanup function, and check if (!ignore) before calling setUser inside the .then(). You'll see this exact pattern generalized by a library in Week 8, so it's worth understanding by hand first.

Part 3 — Auto-save with useEffectEvent:

Back in the Stopwatch from Part 1. Add background auto-save that must never reset its own cadence, even though it needs to read state that changes constantly — the exact scenario Section 6 covers.

  1. Add a sessionLabel text input, state-bound, above the stopwatch controls.
  2. Add a second effect that starts a setInterval firing every 5 seconds, for the lifetime of the component — this effect's dependency array must stay empty; it should never tear down and recreate the interval.
  3. Wrap the actual save logic in a useEffectEvent-created function — localStorage.setItem('stopwatch-session', JSON.stringify({ sessionLabel, laps })) — and call it from inside the interval callback.
  4. Prove it works: type into the label field and record a couple of laps while watching the console/localStorage — the saved snapshot should always contain the latest sessionLabel and laps, and the 5-second save cadence should never visibly reset no matter how often you type or click Lap.
Hint

If your instinct is to add sessionLabel and laps to the auto-save effect's dependency array "to be safe," resist it — that's precisely what would restart the 5-second timer on every keystroke. The whole point of wrapping the save logic in useEffectEvent is that it doesn't need to be a dependency to see current values.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does calling setCount(count + 1) twice in a row not add 2 to the count?

Both calls close over the same stale count value from the current render — neither call sees the effect of the other, since state doesn't update until the next render. Using the functional form, setCount(prev => prev + 1), fixes it because React guarantees each functional update sees the result of the previous queued update.

Q2

What's the practical difference between an empty dependency array [] and no array at all?

[] tells React there's nothing to compare, so the effect runs exactly once, after the first render. Omitting the array entirely means React has nothing to check against at all, so the effect runs after every single render — almost never what you actually want.

Q3

What does the function returned from a useEffect callback do, and when does React call it?

It's the cleanup function, used to undo whatever the effect set up (clear a timer, remove a listener, cancel a subscription). React calls it right before running the effect again on a subsequent render, and one final time when the component unmounts — without it, anything the effect set up keeps running or listening forever, even after the component is gone.

Q4

Why does an effect that calls setCount(count + 1) while depending on [count] cause an infinite loop?

Each run of the effect changes count, which is exactly what the dependency array is watching, so the changed value re-triggers the same effect — which changes count again, forever. React has no built-in guard against this; breaking the cycle requires either removing the dependency, adding a stopping condition, or moving the update to something that isn't itself watching the value it changes.