Skip to content
Back to blog
Production noteFeatured•Sep 24, 2026

Most React state bugs start with two sources of truth

A practical way to decide what belongs in state, what should be derived, and who owns the final answer.

ReactStateArchitectureUX

The bug usually looks smaller than the design mistake

A search result updates, but the selected detail panel still shows the old item. A form says it saved, but the badge beside it still says "draft." A list changes after a refetch and the highlighted row points to an object that no longer exists.

These look like rendering bugs. Often they are ownership bugs: two parts of the UI each believe they have the authoritative value.

The fix is rarely another useEffect. First decide where the truth lives.

Separate facts, choices, and calculations

When reviewing a component, I sort its values into three categories:

  • Facts from outside: a server response, URL parameter, or incoming prop.
  • User choices: a selected item ID, an open panel, or a draft input.
  • Calculations: a filtered list, subtotal, or selected object found from an ID.

Only the second category automatically needs local state. The first has an owner outside the component. The third can usually be computed during render.

import { useState } from 'react';
 
type Job = { id: string; title: string; status: string };
 
function JobList({ jobs }: { jobs: Job[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const selectedJob = jobs.find((job) => job.id === selectedId) ?? null;
 
  return (
    <div>
      {jobs.map((job) => (
        <button key={job.id} onClick={() => setSelectedId(job.id)}>
          {job.title}
        </button>
      ))}
      <aside>{selectedJob?.status ?? 'Select a job'}</aside>
    </div>
  );
}

Storing the entire selected job object instead would create a second copy of server data. After a refetch, that copy could silently become stale. Keeping the ID preserves the user's choice; deriving the object gives the screen the latest facts.

Name the authority for every write

Local state is appropriate for an unsaved draft. The server is authoritative for a saved record. The transition between them is where interfaces get messy.

For example, a profile editor can keep draftName locally while the user types. On save, the server validates and returns the canonical name. The UI then updates from that returned value or refreshes its server data. It should not treat the draft as proof that the write succeeded.

A useful review question is: if the server changed this value in another tab, which copy would eventually win? If the answer depends on timing, the ownership boundary is unclear.

Model phases instead of contradictory booleans

Multiple booleans can represent impossible states: isSaving, hasSaved, and hasError can all be true at once. A small phase model makes the intended transitions visible.

type SaveState =
  | { status: 'idle' }
  | { status: 'saving' }
  | { status: 'saved'; savedAt: string }
  | { status: 'error'; message: string };

This is useful when the states matter to the user. It is unnecessary ceremony for a button that only needs isOpen.

When an Effect is right

Effects are for synchronization with an external system: a browser API, subscription, third-party widget, or request triggered by a changing query. They are a poor default for copying one React value into another. If fullName is derived from firstName and lastName, calculate it; don't synchronize it after render.

React's own guidance makes the same distinction in Choosing the State Structure and You Might Not Need an Effect.

The test I use before adding state

Ask three questions:

  1. Can this value be calculated from existing data?
  2. If it changes, who is allowed to decide the new value?
  3. What should happen when fresh server data arrives?

If those answers are explicit, the component often gets smaller. More importantly, it stops disagreeing with itself. That's the kind of React performance improvement users actually notice: a screen they can trust.

If you are building a web product and want it faster, cleaner, and easier to operate, I am open to collaborations.