Why do two screens show different values for one customer? Because each screen keeps its own copy: one reads a cached list, another a context filled at login, a third adds up the total. In a React and Supabase app the fix is one owner per piece of state: the server owns the data, one query cache mirrors it.
Two screens show different values: what consistent state management is
Consistent state management means every value an app shows has exactly one owner, and every screen reads from that owner or derives from it. Two screens show different values when each holds its own copy: a cached list, a context filled at login, a total computed twice. Server data belongs to the server.
State, in this sense, is every value the interface shows or remembers: a plan name, a credit balance, a filter, an open menu, a half-typed message. Giving each one a single owner is one of the engineering standards for AI-assisted teams I hold a codebase to.
Six kinds of state cover nearly everything a SaaS app holds. These are my rules for who owns each, stated once here and used for the rest of the page:
| Kind of state | The one owner | Where it lives | What must never hold a copy |
|---|---|---|---|
| Server data: the rows the database holds (projects, invoices, messages) | The server | One client query cache mirrors it | Component state, a context, local storage |
| Session and entitlements: who the user is, their plan and role | The server | The query cache, refetched after any change to plan or role | A context filled once at login, local storage |
| URL state: filters, tabs, sort, page number | The URL | The address bar, so a refresh and a shared link agree | Component state that a refresh wipes |
| Form drafts: what the user has typed but not sent | The form, until submit | The form’s own state (a saved local draft at most), discarded after submit | A global store that outlives the form |
| Interface state: open menus, toasts, a selected row | The component | Component state, allowed to die on refresh | Browser storage, the server |
| Derived values: totals, counts, labels | The function that computes them from the owner | Nowhere: computed on render, never stored | Any state variable, any second function |
AI-built apps drift away from this for a plain reason, as I read it: each prompt session solves its own screen. The billing page gets its own fetch, the header gets a context filled at login, the dashboard gets its own total, and each one works on the day it ships. Nothing ties them together until a customer looks at two of them side by side.
What is single source of truth, applied to app state
A single source of truth is one authoritative place for each fact, with every other appearance a read or a derivation. For a SaaS the authority for business data is the database behind the server; the client mirrors it in one cache and keeps no second copy.
That definition is mine. Redux uses the phrase for the first of Redux’s three principles: “The global state of your application is stored in an object tree within a single store.” One store is one way to get a single source on the client, and it is not the definition. The client’s job is to mirror the server honestly and to say when its copy is stale. A single source is also per fact, not one giant store: the plan name has one owner, the open menu has a different one, and neither needs to live next to the other.
If you meant two monitors showing different things
The same words also describe a display-settings problem. This page is about screens inside a web app. For two physical monitors, Microsoft’s multiple-monitors help covers the Duplicate option (“See the same thing on all your displays”) and the Extend option, which shows your desktop across multiple screens.
What goes wrong without it
Each row below is what duplicated state produces, as I read the pattern: what the customer reports, the copy behind it, and the part of this page that removes it.
| What the customer sees | The copy that caused it | Where the fix is |
|---|---|---|
| The dashboard count and the list on the next page disagree | A total computed in two places from two fetches | Derived values in one function; one query cache |
| The value is right after a refresh and wrong before it | A mutation that never invalidated the cached query | Every mutation invalidates its keys |
| The value is wrong after a refresh: the change vanished | It only ever lived in memory or local storage and was never saved | The refresh check in the verify section |
| The header still shows the old plan after an upgrade | Entitlements copied into a context at login | Read entitlements fresh from the server |
| Two open tabs disagree | No refetch on focus and no signal between tabs | Two tabs, two devices, and Realtime |
| The screen shows a save the database never had | An optimistic update with no rollback when the request failed | Roll back on error; compare with the row |
| An old role or price survives a change on the server | A local storage copy that outlives the server’s value | Nothing about money or roles in the browser |
A total computed in two places is also duplicate code, the kind you hunt down when working out how to find unused code in a repo. When the old plan in the header follows a payment, the row itself may be the stale part, and that case is stale UI after a Stripe webhook.
The hardest version to spot is a success message backed by nothing. In a Q&A app I audited in June and July 2026, the last migration rewrote the sign-up trigger in place and dropped the loop that made usernames unique, while the UNIQUE constraint stayed; the next person whose username collided got no account, and the app showed them “account created offline, it will sync” with nothing behind it. The lesson I take from it: a screen that reports a save the database never made is a second source of truth, and comparing the screen with the stored row, the fifth check below, is the check built to catch it.
How to do it on React, Next.js and Supabase
State ownership is fixed in 6 steps on a React and Supabase app: map every copy of the values customers see, route all server data through one keyed query cache, invalidate after every mutation, revalidate the framework’s caches, keep only URL and draft state in the browser, and write the rules down.
On my reading, apps from Lovable, Bolt and v0 mostly come out as React, with Vite or Next.js, often on Supabase, and apps grown in Cursor or Claude Code are often the same stack. The steps are written for it; the rule carries to any other.
Map every copy first
I’d start with the five to ten values customers care about most: the plan, the balance or credits, item counts, the profile name, the main list. For each one, search the client code for every place it appears: fetch calls and Supabase queries (supabase.from(), useState and useReducer holding fetched data, contexts and stores such as Redux or Zustand, localStorage and sessionStorage keys, and every place a total is added up. Fill one row per value:
| Value | Where it is fetched | Where it is stored | Who writes it |
|---|---|---|---|
| Plan name (filled example) | A subscription query; also read once at login | The query cache; an auth context | The payment webhook, on the server |
| Project count (filled example) | The projects list query; a separate count query | The query cache; a dashboard state variable | Create and delete actions |
Any value with more than one place in “Where it is stored” is a finding. Both filled examples above are findings. I’d allow about an hour for a small app.
Server data: one query cache, keyed and invalidated
All reads of server data go through one client cache, TanStack Query or SWR, behind named hooks that live in one folder. My rule: query keys are defined in one file, so two screens asking for the same thing share one cache entry instead of two.
React’s own docs back the reason. React’s guide to choosing the state structure lists “Avoid duplication in state” and warns: “When the same data is duplicated between multiple state variables, or within nested objects, it is difficult to keep them in sync.” You Might Not Need an Effect adds: “When something can be calculated from the existing props or state, don’t put it in state. Instead, calculate it during rendering.” As I read those two lines, a copy of fetched data in useState or a context is that duplication, and a stored total is the kind of value the second line says to calculate during rendering.
Every mutation then invalidates or updates the keys it touches. TanStack Query’s invalidation guide does it in the mutation’s onSuccess with queryClient.invalidateQueries. SWR’s mutation docs describe “the global mutate API which can mutate any key and the bound mutate API which only can mutate the data of corresponding SWR hook”. One file per resource, in TanStack Query v5:
// queries/projects.ts: every screen reads and writes projects through here
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchProjects, addProject } from './api' // Supabase calls that throw on error
import { projectKeys } from './keys' // every query key, in one file
export function useProjects() {
return useQuery({ queryKey: projectKeys.all, queryFn: fetchProjects })
}
export function useAddProject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addProject,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: projectKeys.all })
},
})
}
The comment on the import matters. Supabase says “Every supabase-js call returns a { data, error } pair instead of throwing” , while TanStack Query’s docs say that for a query to count as failed, “the query function must throw or return a rejected Promise”. A function that returns data without checking error never throws, so TanStack Query never sees the failure as an error. Two more rules of mine: an optimistic update always carries a rollback on error, and a global store such as Redux or Zustand holds only true client state, never a copy of a query.
Next.js: revalidate what the write changed
A Server Function or Route Handler that writes must also clear the cached pages that show what it changed. revalidatePath “allows you to invalidate cached data on-demand for a specific path” and “can be called in Server Functions and Route Handlers”. From a Server Function it “Updates the UI immediately (if viewing the affected path)”; from a Route Handler the revalidation “is done on the next visit to the specified path”. The same reference warns that “only the specified path gets fresh data on the next visit”, and its own example ends with a second page marked “Still shows stale data” until the shared tag is also revalidated with revalidateTag or updateTag. That is the two-screens bug inside the framework.
Next.js 16 introduced Cache Components under the cacheComponents flag, and the guide for apps not using them is titled “Caching and Revalidating (Previous Model)” , so check which model your app runs before copying a recipe. The symptoms of a missing call, as I read them: back-navigation shows the old value, or one page updates while another that reads the same data does not. Test this on a production build: the previous-model guide notes that “In Development, Pages are always rendered on-demand and are never cached.”
Two tabs, two devices, and Supabase Realtime
The cheap fix for a second tab is refetch on focus. TanStack Query’s window-focus guide says: “If a user leaves your application and returns and the query data is stale, TanStack Query automatically requests fresh data for you in the background”, and refetchOnWindowFocus defaults to true.
Values that change under the user, such as credits spent by a background job or a teammate’s edit, need more. My rule: a Supabase Realtime subscription to Supabase Postgres Changes should invalidate the matching query key, never write the payload into a second copy. Supabase notes that “Postgres Changes authorizes every event against each subscriber” , one check per subscribed user for every change, which is one more reason to subscribe only to the tables a screen actually shows. Setting it up and debugging it is its own topic: Supabase Realtime slow or not working.
For sign-out and plan changes across tabs, the Broadcast Channel API “allows basic communication between browsing contexts (that is, windows, tabs, frames, or iframes) and workers on the same origin”. Send a message that tells the other tabs to invalidate, not one that carries the new value.
What may live in the browser: URL, drafts, and nothing about money or roles
Filters, sort order, the open tab and the page number belong in the URL. Unsent form drafts may live in local storage, with a version and an expiry. Plan, role, price, balance and feature flags never do: they are read from the server and enforced on the server. Those are my rules, and the last one is where the stakes sit.
In my June and July 2026 audits, 10 of the 21 third-party apps trusted the client: the server accepted whatever the browser asserted. Those 21 are the 11 apps of my deep-audit set and 10 held-out apps I audited blind. They are a selected set of audited apps, not a random sample, and the count is no rate for AI-built apps in general.
Any store you do persist needs a version number, so a deploy can throw away an old shape instead of rendering it.
Write the pattern down where the AI agent reads it
The ownership table only holds if the next prompt session knows it. Put it as rules in the repository’s agent instructions file and in the README:
## State ownership
- Server data is read only through the hooks in src/queries/.
- Never copy query data into useState, a context or a store.
- Query keys come from src/queries/keys.ts; never inline a key.
- Every mutation invalidates the keys it changes in onSuccess.
- Totals, counts and labels come from one function in src/lib/derive.ts.
- Plan, role, price and balance are never written to localStorage.
- Filters, sort and pagination live in the URL.
- Form drafts may be saved locally with a version and an expiry.
- An optimistic update must roll back on error.
- Next.js writes call revalidatePath or revalidateTag for what they changed.
Without it, the next session can add a fourth copy of a value that already has three. How to write that file well is in CLAUDE.md best practices. This state map is also one chapter of a codebase walkthrough for the next engineer, and redrawing it is a first-month job in a legacy code takeover.
How to verify it: test state after page refresh, mutation and navigation
Consistent state is verified with 5 checks per value: change it on one screen and read another without reloading, navigate away and back, hard refresh, read a second open tab, and compare everything with the database row. A value that is right only after a refresh is a missed invalidation.
Run the checks for every value in your inventory, in this order:
- 01 Change the value on screen A, then go to screen B through the in-app links, without reloading. Note what B shows.
- 02 Navigate away from screen B and back again. Note the value.
- 03 Hard refresh and read every screen that shows the value.
- 04 Read the value in a second tab that was already open before the change.
- 05 Read the stored row with a query in the database client and compare it with every screen.
The evidence is a filled matrix, one row per value, dated. Every cell must hold the same value:
| Value | A then B, no reload | Away and back | Hard refresh | Second tab | Database row |
|---|---|---|---|---|---|
| Plan name (example) | Pro | Pro | Pro | Starter | Pro |
The example row fails on the second tab, which points at refetch on focus or a missing cross-tab message. Then test the failure path: make the mutation fail (go offline in the browser’s developer tools, or make the endpoint return a server error) and confirm the screen returns to the stored value. On Next.js, run all of this on a production build, for the reason given in the Next.js step.
Automate the three values that matter most as an end-to-end test that mutates, reloads and asserts on both screens. Playwright’s page.reload “reloads the current page, in the same way as if the user had triggered a browser refresh”. Keep the test file and its passing run as evidence. The narrower question of keeping component state across a refresh is answered by the ownership table: if a value must survive a refresh, it belongs to the server or the URL.
In the Production Hardening Sprint, deliverable 10.2 is verified this way: “Exercise refreshes, mutations, and navigation and verify consistent displayed and stored state.”
Where the sprint does this
Deliverable 10.2, consistent state management, is where we “consolidate conflicting state ownership into a documented, consistent pattern”, because “competing sources of truth can show different values in different screens”. Duplicated logic is a separate deliverable, 10.1: “remove dead code and consolidate duplicated logic while preserving required behavior”. Both land in the production readiness report, deliverable 13.1, verified this way: “account for all 123 IDs; keep failures visible until resolved and explain genuine non-applicable items”. Building new product features or modules, completing unfinished core features or business workflows, and rebuilding core functionality that does not yet perform its intended job sit outside the sprint; new features and completing unfinished core workflows are separate work. The app’s current framework and hosting setup are the starting point; components are refactored or replaced where the production work requires it. Each item is listed in the published scope.
When the stored value is the wrong one
Sometimes the fifth check shows every screen agreeing and the problem sitting below them. If the row is right but a response stays old even after a hard refresh, a server or CDN cache is the likely holder, which falls under caching strategies for web applications. A row that disagrees with another table in the same database calls for the data consistency checklist for SaaS. And when the plan in the row disagrees with what Stripe says the customer pays for, the fix is keeping Stripe subscriptions in sync with the database.
Owning an app means being able to run it, change it and recover it without guessing. The sprint below leaves you with the runbooks and documentation to do that.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase