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 stateThe one ownerWhere it livesWhat must never hold a copy
Server data: the rows the database holds (projects, invoices, messages)The serverOne client query cache mirrors itComponent state, a context, local storage
Session and entitlements: who the user is, their plan and roleThe serverThe query cache, refetched after any change to plan or roleA context filled once at login, local storage
URL state: filters, tabs, sort, page numberThe URLThe address bar, so a refresh and a shared link agreeComponent state that a refresh wipes
Form drafts: what the user has typed but not sentThe form, until submitThe form’s own state (a saved local draft at most), discarded after submitA global store that outlives the form
Interface state: open menus, toasts, a selected rowThe componentComponent state, allowed to die on refreshBrowser storage, the server
Derived values: totals, counts, labelsThe function that computes them from the ownerNowhere: computed on render, never storedAny 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 seesThe copy that caused itWhere the fix is
The dashboard count and the list on the next page disagreeA total computed in two places from two fetchesDerived values in one function; one query cache
The value is right after a refresh and wrong before itA mutation that never invalidated the cached queryEvery mutation invalidates its keys
The value is wrong after a refresh: the change vanishedIt only ever lived in memory or local storage and was never savedThe refresh check in the verify section
The header still shows the old plan after an upgradeEntitlements copied into a context at loginRead entitlements fresh from the server
Two open tabs disagreeNo refetch on focus and no signal between tabsTwo tabs, two devices, and Realtime
The screen shows a save the database never hadAn optimistic update with no rollback when the request failedRoll back on error; compare with the row
An old role or price survives a change on the serverA local storage copy that outlives the server’s valueNothing 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:

ValueWhere it is fetchedWhere it is storedWho writes it
Plan name (filled example)A subscription query; also read once at loginThe query cache; an auth contextThe payment webhook, on the server
Project count (filled example)The projects list query; a separate count queryThe query cache; a dashboard state variableCreate 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:

  1. 01 Change the value on screen A, then go to screen B through the in-app links, without reloading. Note what B shows.
  2. 02 Navigate away from screen B and back again. Note the value.
  3. 03 Hard refresh and read every screen that shows the value.
  4. 04 Read the value in a second tab that was already open before the change.
  5. 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:

ValueA then B, no reloadAway and backHard refreshSecond tabDatabase row
Plan name (example)ProProProStarterPro

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.