ReactPublished 4/9/2025

React Hooks Fundamentals: useState and the useEffect Dependency Array

Understand closures to see why useState needs functional updates, and what the useEffect dependency array is actually comparing.

Starting With a Common Bug

Take a look at this pretty typical piece of code:

js
function Counter() {
  const [count, setCount] = useState(0)
 
  function handleClick() {
    setCount(count + 1)
    setCount(count + 1)
    setCount(count + 1)
  }
 
  return <button onClick={handleClick}>{count}</button>
}

Quick guess: click the button once, does count jump to 3?

Nope, it only becomes 1. This is the first real hurdle in understanding useState: we assume setCount(count + 1) means "add 1 to count," but it actually means "set count to this specific value." The count here is whatever it was during this particular render, and all three calls use the exact same count — so it's really just calling setCount(1) three times in a row.

The Closure Trap: Why State Ends Up Stale

To make sense of this, you need to know that every render is really just React calling the Counter function again. Each call creates a fresh execution context, and count gets redeclared inside it — it's a completely different variable from the count in the previous render. handleClick gets created during one specific render, and the count it remembers is locked to whatever that render's value was. This is a closure at work.

That's also why code like this gets especially messy in async situations:

js
function DelayedCounter() {
  const [count, setCount] = useState(0)
 
  function handleClick() {
    setTimeout(() => {
      setCount(count + 1) // the count here is from the render at click time, not 3 seconds later
    }, 3000)
  }
 
  return <button onClick={handleClick}>{count}</button>
}

Click the button 5 times within those 3 seconds, and the display only goes up by 1. Each setTimeout callback closed over the count from its own render, and none of them know about each other.

The Fix: Functional Updates

setCount also accepts a function, and React passes it the latest state as an argument — so you compute off the current value instead of the possibly-stale one sitting in the closure:

js
function handleClick() {
  setCount((prev) => prev + 1)
  setCount((prev) => prev + 1)
  setCount((prev) => prev + 1)
}
// count correctly goes up by 3 this time

A good rule of thumb: whenever the new state depends on the previous state, reach for a functional update instead of reading the variable straight out of the closure. Get in this habit and you'll sidestep a huge chunk of stale-state bugs before they happen.

useEffect: What Is the Dependency Array Actually Comparing?

Once you get the closure behavior behind useState, useEffect starts making a lot more sense — it's running on the same logic. The dependency array decides whether the effect function created during this render should actually run.

js
useEffect(() => {
  console.log('effect ran')
}, [count])

After every render, React compares this render's dependency array ([count]) against the one it recorded from the previous render. The comparison uses Object.is, meaning a shallow compare: if every value in the array matches the previous render, the effect gets skipped; if even one value differs, React runs the new effect function this render produced.

Here's a subtle point people often get wrong: the dependency array isn't telling React "watch these for changes." It's telling React "this effect reads these external values." Think of it as a disclosure list, not a subscription list.

Never lie to the dependency array! Never lie to the dependency array! Never lie to the dependency array!

It matters enough to say three times.

Why Objects and Functions in the Dependency Array Cause Trouble

Shallow comparison is where the dependency array trips people up most, especially with objects and functions:

js
function SearchBox({ keyword }) {
  const options = { keyword, limit: 10 } // a brand-new object on every render
 
  useEffect(() => {
    fetchResults(options)
  }, [options]) // infinite loop!
 
  return <input />
}

options is an object literal created inside the function body, so every render produces a new reference — even if the contents are identical, Object.is says they're different. That means the effect fires on every render, and if the effect triggers another render (say, by updating some state), you've got an infinite loop.

There are two common fixes. The first is to break the dependency down to the actual primitive values instead of the whole object:

js
useEffect(() => {
  fetchResults({ keyword, limit: 10 })
}, [keyword]) // only depend on the primitive that actually changes

The second is useMemo or useCallback, for when the object or function genuinely needs a stable reference. But these hooks aren't free either — overusing them just adds complexity, so breaking dependencies down to primitives is usually the cleaner move.

What Happens When You Forget a Dependency?

The opposite problem shows up just as often: a variable missing from the dependency array.

js
function Profile({ userId }) {
  const [user, setUser] = useState(null)
 
  useEffect(() => {
    fetchUser(userId).then(setUser)
  }, []) // missing userId
 
  return <div>{user?.name}</div>
}

This works fine on first mount, but if the userId prop ever changes afterward, the screen still shows the old user's data — because the effect never runs again. The dependency array is missing userId, so the effect is stuck using whatever userId was on the very first render.

The most reliable way to figure out what belongs in the dependency array isn't guessing — it's going back through the effect body and checking which external values it reads (props, state, or anything declared inside the component). Those are the ones that belong in the array. The react-hooks/exhaustive-deps ESLint rule does exactly this check, and it's worth turning on since it catches these bugs at lint time instead of in production.

Empty Array vs. No Array at All

The second argument to useEffect actually has three distinct states, each with completely different behavior:

js
useEffect(() => {
  console.log('runs after every render')
}) // no dependency array
 
useEffect(() => {
  console.log('runs once, on mount')
}, []) // empty array
 
useEffect(() => {
  console.log('runs when count changes')
}, [count]) // array with dependencies

Skip the dependency array entirely, and the effect runs after every single render — which is rarely what you actually want. An empty array means "this effect doesn't read any external value that can change," so running it once on mount is enough. And an array with dependencies follows the shallow-comparison logic covered above.

Wrapping Up

useState and useEffect look like two separate APIs on the surface, but they're built on the same closure mental model underneath: every render is an independent function call, and the variables declared inside it only exist for that one call. Once that clicks, whether setCount needs a functional update or what belongs in a dependency array stops being a rule you memorize — it becomes something you can just work out yourself.