🪴 GoDeep
Search
← Deep dives

Deep dive · Shipping Software · React front ends

UI Is a Function of State: Why Every React Bug Is a State Bug

React makes one claim about your interface. Take it seriously and most debugging turns into a question about data, not pixels.

7 min read

React is usually explained as a library for building user interfaces out of components, which is true and almost entirely beside the point. The interesting thing about React is that it makes an argument. The argument is that your screen should be a pure function of your data — give the same data, get the same screen, every time, with no hidden memory of what was on screen a moment ago. You describe what the interface should look like for a given set of values, and the library works out which bits of the page to change. You never reach into the page and adjust it yourself.

Everything that feels confusing about React follows from taking that claim seriously, and so does the single most useful debugging habit you can acquire: when the screen is wrong, the screen is almost never the problem. The data behind it is.

What “UI = f(state)” changes about debugging

Before React, front-end code was a sequence of instructions to a document: find this element, change its text, hide that panel, add a row to the table. Bugs lived in the gap between what had happened to the page and what you thought had happened to it. Debugging meant inspecting the page and trying to reconstruct the history of commands that had produced it.

React removes the commands. State — the values a component remembers between renders — goes in, a description of the interface comes out, and the library reconciles that description with the real document. So when a number on a dashboard is stale, or a modal will not close, or a filter shows the wrong results, the useful question is not “what is wrong with this element?” It is: what state produced this, and is that state what I think it is?

That reframing has a practical consequence. Reading render output to find bugs is mostly wasted effort. Look at the values instead. The React Developer Tools extension lets you select a component and see every piece of state and every prop it received. Nine times in ten, the bug is visible right there — a value is undefined, an array has two entries when it should have twenty, a boolean is stuck at true. The rendering was faithful. It was faithfully rendering wrong data.

An agent-written component will often invite the old habit back, because it has been trained on a decade of mixed examples:

// Agent output: reaching into the document to fix the display
function Total({ orders }: { orders: Order[] }) {
  useEffect(() => {
    const el = document.getElementById("total");
    if (el) el.textContent = `£${sum(orders)}`; // React does not know this happened
  }, [orders]);
  return <span id="total">£0</span>;
}
// Better: the total is derived from data, so it can never disagree with it
function Total({ orders }: { orders: Order[] }) {
  return <span>£{sum(orders)}</span>;
}

The first version works until React re-renders for some unrelated reason and overwrites the text with £0. Two sources of truth about the same pixel is the oldest bug in interface programming, and React exists to make it impossible — unless you smuggle it back in.

Three kinds of state, and the one that causes most dashboard bugs

People say “state” as though it were one thing. It is at least three, and conflating them is the root of most trouble in a data-heavy interface.

Server data is a copy of something that lives elsewhere: the survey responses, the list of projects, the user’s permissions. You do not own it. It can change without your page knowing, and it can be wrong the moment it arrives.

UI state is genuinely local and genuinely yours: which tab is open, whether the sidebar is collapsed, the text currently in a search box. Nobody else has an opinion about it.

Derived values are not state at all. The filtered list, the row count, the total, whether the submit button should be disabled — these are functions of the other two. Storing them is a promise to keep them in sync, and that promise is one you will break.

The classic dashboard bug is treating server data as if it were UI state:

// The shape of a hundred agent-written dashboards
const [rows, setRows] = useState<Row[]>([]);
const [filtered, setFiltered] = useState<Row[]>([]); // derived — should not be state

useEffect(() => {
  fetch("/api/responses").then(r => r.json()).then(setRows); // no error handling
}, []);

useEffect(() => {
  setFiltered(rows.filter(r => r.wave === wave)); // an extra render to keep in sync
}, [rows, wave]);

There are two mistakes stacked here. filtered is a derived value being stored, so it is briefly stale on every change and permanently stale if anyone updates rows by another route. And rows is a local mirror of data that lives in a database: nothing refetches it, nothing knows whether it is thirty seconds or thirty minutes old, and two components asking for the same endpoint will keep separate, diverging copies.

The derived part is a small fix — compute it during render:

const filtered = rows.filter(r => r.wave === wave); // always correct, one render

The server-data part is the one worth changing properly. A data-fetching library such as TanStack Query exists because “a cached copy of remote data” is a hard problem with a known set of answers: one cache shared across components, an explicit notion of staleness, deduplicated requests, and loading and error states you cannot forget to handle because they are part of the return value.

const { data: rows = [], isPending, error } = useQuery({
  queryKey: ["responses", wave],       // identity of the data, not of the component
  queryFn: () => fetchResponses(wave),
  staleTime: 30_000,                   // an explicit decision about freshness
});

if (isPending) return <Spinner />;
if (error) return <ErrorPanel error={error} />;

The interesting line is staleTime. It forces you to answer a question the useState version let you avoid: how old is this data allowed to be? For a live job queue, seconds. For a list of survey waves, minutes. Either answer is fine; not having one is how a dashboard ends up confidently displaying yesterday.

Effects are the escape hatch, not the tool

An effect (useEffect) runs code after render to reach outside React — a subscription, a timer, a browser API. The React documentation is now unusually blunt about this: if you are not synchronising with an external system, you probably do not need an effect.

Yet effects are the single most over-used hook in agent-written code, because they are the shape of the old mental model. Anything you would once have written as “and then do this” becomes an effect. Three smells worth grepping for:

  • An effect whose only job is setState from other state. That is a derived value; compute it during render.
  • An effect that fetches data with no cleanup and no cancellation. In development React mounts components twice on purpose to expose exactly this, which is why some agent code “fires two requests” and gets patched with a useRef guard rather than fixed.
  • An effect that responds to a click. Event handlers are for things that happen because the user did something; effects are for things that happen because the component is on screen.

Why an agent’s component can work and still be wrong

Here is the uncomfortable part. A component that renders the right thing on your machine today can be wrong in ways no amount of clicking will reveal, and the failures are specific enough to check for deliberately.

No loading or error state. The component assumes the fetch succeeds. On your laptop, against localhost, it always does. In production, on a train, against a cold Azure App Service instance, it returns nothing for four seconds and then fails — and the interface shows an empty table that looks exactly like “no results”. Every remote read has three outcomes, and an interface that renders only one of them is unfinished.

Index keys. React uses the key prop to decide which element corresponds to which item across renders. key={i} tells React that position is identity. Delete the third row of a list and every row below it shifts up, so React keeps the old component state in place: checkboxes tick the wrong items, a half-typed input jumps to a different row. It is invisible in a static list and reliably wrong in an editable one. Use a stable id from the data.

Stale cache. Two components fetch the same endpoint into separate useState, a mutation updates one, and the other keeps showing the old value until a full page reload. Users learn to press F5 and stop trusting the numbers, which is worse than an error, because an error is honest.

Secrets in the bundle. This one is not subtle, it is only invisible. Everything in a front-end build is shipped to the browser and readable by anyone. A Vite build inlines any environment variable prefixed VITE_ into the JavaScript; an agent that needs a key and finds one in your .env will happily use it.

// Shipped to every visitor, readable in DevTools, permanent once deployed
const res = await fetch("https://api.example.com/v1/extract", {
  headers: { Authorization: `Bearer ${import.meta.env.VITE_OPENAI_KEY}` },
});

The fix is architectural rather than clever: the browser calls your own back end, the back end holds the key. If you find a secret in a front-end file, treat it as leaked and rotate it, because it has been in the bundle since the day it deployed. A quick check on any built site — search the contents of dist/ for the distinctive prefix of your keys — takes a minute and is worth doing on all of them.

The habit worth keeping

React’s claim is narrow and strong: the interface is a function of state. The pay-off is that it collapses a huge class of interface bugs into a single question you can always ask, and answer with data rather than intuition.

So when something looks wrong on screen, resist the urge to inspect the element. Find the state. Ask which of the three kinds it is. Ask whether a derived value has been frozen into storage, whether a copy of server data has any story about going stale, and whether the component knows what to render while it waits and when it fails. Most of the time, the rendering was innocent all along.