How I structure a frontend that owns almost no truth
Most frontend architecture conversations start in the wrong place. Which state library. How to split the folders. Whether to colocate. Those are consequences, not decisions.
The decision underneath is that a frontend owns almost nothing. The server owns the data. The URL owns where the user is. The browser owns focus, history, and scroll. What is genuinely the frontend's to own is small: what is selected right now, what is being typed, what is open. Everything else is borrowed, and the failures come from forgetting which is which.
So the claim I want to defend: every piece of state needs exactly one owner, and structure is what makes a second owner expensive to create.
The bug that made this concrete
A filtered data view. Date range, a few category toggles, a search box, a tab. All of it in component state, because that is what the first version needed.
Then the requests arrived. Share this view with someone. Refresh without losing my filters. Use the back button to undo a filter change. Deep-link into a filtered report from an email.
The tempting fix is to sync: keep the state in useState, mirror it into the URL with an effect, and read the URL back on mount. I have written that. It produces a specific, miserable class of bug. Back navigation changes the URL but not the state, so you re-sync. Now a state change writes to the URL and the URL write feeds back into state. You add a guard flag. Then the tab is in the URL but the scroll position is not, so returning from a detail page restores half the view. Every fix adds another edge case, because the real problem is that two things believe they own the same value.
The fix was deletion, not addition. Filters are URL state. The URL is the only owner. Components read from search params and write by navigation. There is nothing to sync because there is nothing else holding the value.
Diagram
Syncing two owners versus one owner for filter state
Component state mirrored into the URL
- useState holds the filters and an effect writes them to the URL
- Back navigation changes the URL but not the state, so it re-syncs
- URL writes feed back into state and need a guard flag
- Every fix adds another edge case
URL as the only owner
- Filters live in search params and nowhere else
- Components read from the URL and write by navigation
- Refresh, share, back button, and deep links work without extra code
- Nothing to sync because nothing else holds the value
That reframed the whole layer for me. Before adding state anywhere, the question is not "where is this convenient" but "what kind of state is this," and the kind determines the owner.
Four kinds, four owners
Server data is owned by the server and cached on the client. It has a fetch, a staleness story, and an invalidation story. It is never copied into local state so it can be edited in place. Copying it creates a second owner immediately, and the copy is stale the moment a mutation succeeds.
URL state is anything that should survive a refresh, a share, or a back button. Filters, tabs, pagination, selected entity, sort. If a user could reasonably send someone a link and expect the same view, it belongs here. This is the category most apps get wrong, because component state is easier to write on day one and the cost lands in month three.
Form state is owned by the form library, from mount to submit. It is deliberately not application state. A half-typed field is not a fact about the system, and treating it as one is how you get a global store full of drafts nobody cleans up.
Ephemeral interaction state is genuinely local: open menus, hover, drag position, which row is expanded, an optimistic pending flag. It lives in the component that owns the interaction and dies with it. Almost none of this deserves promotion to a shared store.
The fifth category is the one that causes the most damage: derived state, which is not state. A filtered list, a computed total, a validity flag, a formatted label. Storing these means storing the same information twice and writing an effect to keep the copies in agreement. Compute them during render, or compute them before they reach the client. An effect whose only job is to keep two pieces of your own state consistent is a design error, not a synchronization problem.
Diagram
Each kind of state and its single owner
Select a layer to see what it owns.
Owned by the server and cached on the client, with a fetch, staleness, and invalidation story. Never copied into local state to edit in place.
Anything that should survive a refresh, a share, or a back button, such as filters, tabs, pagination, selection, and sort.
Owned by the form library from mount to submit. A half-typed field is not a fact about the system.
Open menus, hover, drag position, expanded rows, optimistic pending flags. Lives and dies in the component that owns the interaction.
Filtered lists, totals, validity flags, formatted labels. Computed during render or before reaching the client, never stored.
The server and client boundary needs enforcement, not etiquette
"Server components fetch, client components handle interaction" is a slogan until something stops you from breaking it.
What makes it real is direction. Data readers live in server-only modules and import a marker that throws at build time if they are ever pulled into a client bundle. Aggregation happens on the server or in a pure module. A chart component receives finished series and renders them. It does not sum, group, convert currencies, or resolve date ranges.
That last one matters more than it sounds. When a chart computes its own totals, a card elsewhere on the page computes the same total its own way, and the two disagree over rounding or over which rows to include. Nobody notices for a while, and then a user notices and stops trusting the whole screen. One computation, one place, both surfaces consume the result.
The practical rule I use: a client component may be interactive, but it should not be the first place a number comes into existence.
Three tiers, one direction
Components fall into three tiers, and the tier is decided by what the component is allowed to know.
Primitives know nothing about the domain. Button, dialog, field, table shell, sheet. They take generic props and are the only place styling is expressed.
Domain components know the shape of a domain object but not where it came from. A row, a summary card, a chart panel. They take typed props and emit callbacks. They do not fetch, and they do not read global state.
Route compositions know everything: they fetch, arrange, pass callbacks down, and own the page's structure.
Dependencies point one way. Primitives never import domain components. Domain components never import route code. When someone reaches for a domain concept inside a primitive, that is the signal a new domain component is missing.
Diagram
Three component tiers with one dependency direction
Select a layer to see what it owns.
Know everything. They fetch, arrange, pass callbacks down, and own the page's structure.
Know the shape of a domain object but not where it came from. Typed props in, callbacks out, no fetching or global state.
Know nothing about the domain. Button, dialog, field, table shell, sheet, and the only place styling is expressed.
The test for which tier a component belongs to: could it be rendered in isolation with hand-written props? If not, it is a route composition wearing a smaller name.
Props are the contract, and formatting is the edge
The single-owner idea shows up again in a smaller place: where a number stops being a number.
// Domain component receives values, not rows, and not strings.
type SummaryCardProps = {
label: string;
amountMinor: bigint;
currency: Currency;
/** Non-empty means the figure is partial and the card must say so. */
excluded: readonly ExcludedSource[];
trend?: { direction: "up" | "down"; deltaMinor: bigint };
};
Two things this rules out. The component does not receive a raw database row, so a schema change does not ripple into the view layer, and the component cannot quietly start reading a field it was not given. And it does not receive "$1,240.00", because a pre-formatted string cannot be re-formatted for a different locale, cannot be compared, and hides whether the figure was complete.
Formatting is a rendering concern and belongs at the last possible moment, in the component that displays it. Values travel; strings arrive.
The excluded field is the deliberate part. When data is partial, the type says so and the component cannot render the amount without stepping over the evidence. Making the honest path the shortest path is more reliable than a code review comment asking for it.
Performance is mostly a placement problem
The interventions that matter are structural, not micro-optimizations.
A component that updates every second, a countdown or a live clock, is its own subtree with its own state. When that state sits higher up, the entire page re-renders on a tick, and no amount of memoization further down repairs it. Isolate the thing that changes fast.
Long lists are windowed. A table rendering two thousand rows is not slow because React is slow, it is slow because two thousand rows exist in the DOM.
Heavy computation does not run in a client component at all, which is the same boundary as before, arriving from a different direction. And URL state helps here too: because the view is reconstructible from the URL, a navigation is a fresh render from a known input rather than an incremental patch on state that has been mutating for ten minutes.
Layout constraints are structural in the same way. The app has to work at 320px with no horizontal page overflow, so dense tables live in scroll containers and the filter bar is one responsive component that keeps inline controls on desktop and collapses into a sheet on mobile. Same state, same URL params, different affordance. Two separate implementations of a mobile and desktop filter bar means two places to update and one of them will lag.
What this costs
Boundaries are not free and pretending otherwise is a tell.
URL state is more verbose than useState. You write parsers, you validate params, you handle garbage input, and you accept that every filter change is a navigation. On a view with two filters that nobody shares, useState is the right call and I use it.
Three tiers means more files. A small feature can touch a primitive, a domain component, and a route composition. That feels like ceremony when the feature is a single card.
Passing values instead of rows means more prop plumbing, and there are days you would rather hand the whole object down.
I would not pay any of this on a prototype or a page that will be deleted in a quarter. It pays off when a view is long-lived, shared between surfaces, or displays something a user will act on.
Whether the structure held
The honest test is a feature nobody planned for. Two arrived.
Deep-linking from notifications into a specific filtered view was close to free, because the filters already lived in the URL. The feature was a link. Under the sync-two-owners version it would have been a rewrite of the filter layer.
Cross-surface reuse of a summary figure was the same story from the other side. The number already existed as a server-side computation with a typed result, so a second surface consumed it rather than recomputing it. Had the first consumer been a chart that summed its own props, the second would have written its own version and the two would have drifted.
Neither was clever. Both were cheap because a placement decision made early meant there was only one thing to change.
The questions I ask in a frontend review
- What kind of state is this, and does anything else hold the same value?
- Would a user expect this to survive a refresh, a share, or the back button?
- Is this effect synchronizing two pieces of our own state? If so, what can be deleted?
- Is this the first place this number comes into existence on the client?
- Could this component render in isolation with hand-written props?
None of it is exotic. It is mostly refusing to let a value have two homes, and noticing early, while moving it is still a small change rather than an argument about a rewrite.
References
- React: You Might Not Need an Effect: the canonical case against synchronized derived state.
- Next.js App Router: server and client component boundaries, and search params as state.
- TanStack Query · React Hook Form: owners for server cache and form state.
- TanStack Virtual: windowing long lists.
- Radix Primitives · WAI-ARIA APG: the primitive tier, and behavior worth not reimplementing.