Skip to main content

Command Palette

Search for a command to run...

State is a Record of the Past, Not a Claim About the Present

Published
3 min read

One of the most common React mistakes I see is using useEffect to keep two pieces of state in sync. It seems intuitive, but it introduces a subtle bug — and more importantly, it reveals a misunderstanding about what state actually represents.

The Problem

Imagine you have a list of items and a selected item ID. When items change, you need to clear the selection if the selected item no longer exists.

Here's the instinctive (but wrong) approach:

function ItemList() {
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const items = useItems()

  useEffect(() => {
    if (selectedId !== null && !items.some((item) => item.id === selectedId)) {
      setSelectedId(null)
    }
  }, [items, selectedId])

  return <List items={items} selectedId={selectedId} />
}

This looks reasonable, but there's a hidden problem: for one render, selectedId points to an item that doesn't exist.

React renders with stale state, then the effect runs and fixes it. During that intermediate render, any code that assumes selectedId is valid will break.

But there's a deeper issue here — this code misunderstands what state is for.

The Mental Shift

When you call setSelectedId("abc"), you're not declaring "item abc is selected." You're recording an event: "the user selected item abc."

State is a record of what happened. It's historical. It doesn't make claims about whether that selection is still valid — that's a separate concern.

This reframing changes everything. Watch what happens when we rename the variable to reflect what it actually holds:

function ItemList() {
  const [lastSelectedId, setLastSelectedId] = useState<string | null>(null)
  const items = useItems()

  const selectedId = useMemo(
    () => items.some((item) => item.id === lastSelectedId) ? lastSelectedId : null,
    [items, lastSelectedId],
  )

  return <List items={items} selectedId={selectedId} />
}

Now the code reads honestly:

  • lastSelectedId — the last ID the user selected (a fact about the past)

  • selectedId — the currently valid selection (derived from present reality)

There's no useEffect. No sync. No intermediate broken render. The derivation happens synchronously during render, so selectedId is always consistent with items.

The Principle

State records what happened. Derived values describe what's true now.

When you try to store "what's true now" directly in state, you end up fighting to keep it updated — hence the useEffect trap. But when you store the raw historical fact and derive current truth from it, everything stays in sync automatically.

A Reusable Hook

This pattern comes up often enough that it's worth extracting:

function useSelection<TItem, TKey extends keyof TItem>(
  items: TItem[],
  identifierKey: TKey,
): [TItem[TKey] | null, (id: TItem[TKey] | null) => void] {
  const [lastSelectedId, setLastSelectedId] = useState<TItem[TKey] | null>(null)

  const selectedId = useMemo(
    () => items.some((item) => item[identifierKey] === lastSelectedId) ? lastSelectedId : null,
    [items, identifierKey, lastSelectedId],
  )

  return [selectedId, setLastSelectedId]
}

Usage is clean:

function ItemList() {
  const items = useItems()
  const [selectedId, setSelectedId] = useSelection(items, 'id')

  return <List items={items} selectedId={selectedId} />
}

The hook encapsulates the pattern: store the user's action, derive the valid state. Consumers get a selectedId that's always safe to use.

Takeaway

Next time you reach for useEffect to "fix" state based on other state, pause. Ask yourself: am I storing a fact about the past, or am I trying to store current truth?

Store the past. Derive the present.