Duncan Leung
Understanding useEffect: Each Render Has Its Own Props, State, and Effects
Published on

Understanding useEffect: Each Render Has Its Own Props, State, and Effects

Authors

When I first started using useEffect, I kept reaching for the old class-component lifecycle mental model: componentDidMount, componentDidUpdate, componentWillUnmount. I would try to write effects that behave differently depending on whether the component was rendering for the first time or not.

That mental model fights against how useEffect actually works, and it produces a lot of subtle bugs — stale closures, missing dependencies, and infinite loops.

This post is my attempt to write down a mental model that has helped me reason about effects more consistently.


TLDR;

  • useEffect is not a lifecycle hook. It is a way to synchronize side effects with the current props and state.
  • Each render has its own props, state, event handlers, and effects. They are captured at the time of that render.
  • The dependency array tells React when it is safe to skip synchronizing — not what the effect "depends on" in a general sense.
  • When you fight the data flow (missing deps, mutable refs, stale closures), you are usually working around something the effect is trying to tell you.

The Old Mental Model: Mount / Update / Unmount

With class components, I trained myself to think about effects in terms of when they fire:

  • "Run this on mount."
  • "Run this when this prop changes."
  • "Clean this up before unmount."

This framing pushes you to write effects that behave differently on the first render than on subsequent renders. It also tempts you to "skip" updates by stripping things out of the dependency array.

The hidden cost is that the same component can end up in inconsistent states depending on the order of prop changes.

The Mental Model That Actually Works: Synchronization

useEffect is not really about lifecycle. It is about synchronizing things outside of the React tree (the DOM title, a subscription, a fetched resource) with the props and state inside the React tree.

The framing that helped me click:

  • It should not matter whether the component rendered with props A, B, C in sequence, or jumped straight to C.
  • There may be temporary differences (e.g. while data is fetching), but eventually the side effect should reflect the current props and state.
function Greeting({ name }) {
  useEffect(() => {
    document.title = 'Hello, ' + name
  })

  return <h1>Hello, {name}</h1>
}

There is no "mount" or "update" here. There is just: the document title should match name. React calls our effect after every render to keep that promise.


Each Render Has Its Own Props and State

This is the single most important idea, and it took me an embarrassingly long time to internalize.

Inside any particular render, props and state are constants. They do not change over time within that render. They are captured by the closure of that specific render.

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

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

When setCount(1) is called, React does not "mutate" count. React calls our function again — and the new render has its own count value of 1.

The count inside any given render does not change. The component re-runs, and each run sees its own value.

Each Render Has Its Own Event Handlers

Because props and state are captured by the render, event handlers defined inside the render see those captured values — even if they fire asynchronously.

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

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count)
    }, 3000)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  )
}

If I click "Show alert" when the count is 3, then quickly click "Click me" a few more times, the alert will still say "You clicked on: 3" — because handleAlertClick belongs to the render where count was 3.

This is not a bug. The event handler captured the value from its render.

Each Render Has Its Own Effects

The same rule applies to effects.

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

  useEffect(() => {
    document.title = `You clicked ${count} times`
  })

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

There is one conceptual effect ("update the document title"), but it is a different function on every render, each one closed over its own count. React runs the effect for the latest render after it commits to the DOM.


useEffect Cleanup Also Belongs to Its Render

The cleanup function returned from an effect closes over the same props and state as the effect itself.

// First render, props are `{ id: 10 }`
useEffect(() => {
  ChatAPI.subscribeToFriendStatus(10, handleStatusChange)
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange)
  }
})

// Next render, props are `{ id: 20 }`
useEffect(() => {
  ChatAPI.subscribeToFriendStatus(20, handleStatusChange)
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange)
  }
})

When React runs the cleanup for the first render, it cleans up id: 10 — not the current id: 20. This is what makes subscriptions correct: each render is responsible for tearing down its own work before the next render's effect runs.


Reading the Latest Value with useRef

Sometimes you genuinely want to read the latest value, not the captured one — typically inside a callback fired long after the render.

The escape hatch is useRef, because a ref's .current is a mutable container that lives outside the render closure.

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

  useEffect(() => {
    latestCount.current = count

    setTimeout(() => {
      console.log(`You clicked ${latestCount.current} times`)
    }, 3000)
  })

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

This is an escape hatch, not a default. Reach for it only when you really want to break out of the render's closure on purpose.


The Dependency Array Is Not a "When to Run" List

The most common bug I see (and have written) is treating the dependency array as a list of "things I want to re-run this effect for." That is the wrong mental model.

The dependency array is React's way of asking:

"Given that I am going to re-run this effect to keep things synchronized, when is it safe to skip it because nothing it uses has changed?"

The rule is mechanical:

  • Every value from inside the component that the effect uses must be listed.
  • That includes props, state, and functions defined inside the component.

If you lie about dependencies, you create stale closures. The bug shows up as "this effect somehow has old data."


Fetching Data: The Empty Array Trap

Here is the canonical version of this bug:

function SearchResults() {
  async function fetchData() {
    // ... uses `query` from props ...
  }

  useEffect(() => {
    fetchData()
  }, []) // 🚨 Lying about deps
}

fetchData uses query, but the effect claims it has no dependencies. The result: the search never re-runs when the query changes.


Infinite Loops and How to Actually Fix Them

A classic infinite loop:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1)
  }, 1000)
  return () => clearInterval(id)
}, [count]) // 🚨 Interval is torn down and re-created every tick

The instinct is to remove count from the deps. But that would be lying — and the effect would see a stale count forever.

The fix is to remove the need for the dependency, not the dependency itself.

Strategy 1: Functional Updater Form

If you only need count to compute the next state, use the functional form of setState. React already knows the current value — you just need to tell it how to change.

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1)
  }, 1000)
  return () => clearInterval(id)
}, []) // ✅ Deps are honest — the effect no longer reads `count`

The effect no longer reads count from the render scope, so it does not need it as a dependency.

Strategy 2: useReducer

If you find yourself writing setSomething((s) => ...) repeatedly, or if the next state depends on multiple pieces of current state, reach for useReducer.

const initialState = { count: 0, step: 1 }

function reducer(state, action) {
  const { count, step } = state
  if (action.type === 'tick') {
    return { count: count + step, step }
  } else if (action.type === 'step') {
    return { count, step: action.step }
  }
  throw new Error()
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState)

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' })
    }, 1000)
    return () => clearInterval(id)
  }, [dispatch]) // ✅ `dispatch` identity is stable
}

The effect no longer needs to read count or step. It just dispatches an action — what happened — and the reducer decides how state updates in response.

This decouples "what happened" from "how state changes," which is what lets the effect stay free of state dependencies.

Strategy 3: Move the Function Into the Effect

If a helper function is only used inside an effect, move it into the effect. That removes it from the render scope and from the dependency list:

function SearchResults() {
  const [query, setQuery] = useState('react')

  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query
    }

    async function fetchData() {
      const result = await axios(getFetchUrl())
      setData(result.data)
    }

    fetchData()
  }, [query]) // ✅ Honest deps — refetches when `query` changes
}

Strategy 4: Hoist the Function Out of the Component

If a function does not use anything from the component scope, hoist it out of the component entirely. It is then not part of the data flow and does not belong in the deps list:

// ✅ Lives outside the render — not part of the data flow
function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query
}

function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react')
    // ... fetch and do something ...
  }, []) // ✅ Deps are honest
}

Strategy 5: useCallback

When a function must live inside the component (because it uses props or state) and is passed down or used inside an effect, wrap it in useCallback. That gives the function a stable identity that only changes when its own inputs change.

function SearchResults() {
  const [query, setQuery] = useState('react')

  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query
  }, [query])

  useEffect(() => {
    const url = getFetchUrl()
    // ... fetch and do something ...
  }, [getFetchUrl]) // ✅ Re-runs only when `query` (and thus `getFetchUrl`) changes
}

With useCallback, the function participates in the data flow: when its inputs change, its identity changes, and downstream effects re-run.


The Big Idea

If I had to compress all of this down to one sentence:

Stop thinking about when an effect runs. Start thinking about what it synchronizes, and let the dependency array tell the truth.

When I follow that, most of the "effect feels weird" bugs disappear on their own — because the design of useEffect is trying to point me at a change in the data flow I had not noticed yet.


Further Reading

The mental model in this post is heavily influenced by Dan Abramov's A Complete Guide to useEffect, which is required reading if you write React effects.