Invental/Review lessons/Client state that goes stale

10 lessons · 15 checks

Client state that goes stale

In the browser, the value you act on is often a copy: a debounced mirror of an editor, a persisted cache, a target captured before an await. By the time the code uses it, the user has typed more, switched account or moved on. Each lesson is one place where a stale copy wins over the truth.

Major

Late async results must not overwrite newer ones

An async result must prove it is still wanted before it writes: every path that changes the same field has to invalidate in-flight work, not just the next upload.

Read the lesson →
Major

Refresh every store a server change touches

When a server-side change filters several resources, re-fetch every client store that caches one of them, not just the store for the thing you changed.

Read the lesson →
Major

Act on live state, not a debounced copy

A store field that mirrors an editor through a debounce is fine for display but wrong for actions; read the source of truth at the moment of the action, and re-check “where am I?” after every await.

Read the lesson →
Major

Guard cache writes with scope and a version

In a persisted client cache, every async write needs two guards captured before the await: whose data it is (identity scope) and whether something newer was written meanwhile (a version or mutation counter), because timestamps alone can tie.

Read the lesson →
Major

Never roll back a change the server accepted

Put only the server call inside the rollback block; once the server has accepted a mutation, a failed refresh is a logging problem, not a reason to undo the user's change.

Read the lesson →
Major

No global store writes during a server render

A client store that is a module singleton becomes shared state across users the moment it runs on a server; pass server-loaded data down per request (context or props), never by writing it into the global store during render.

Read the lesson →
Minor

Clearing a cache does not stop in-flight loads

When a push event invalidates a cache, every request already in flight must be able to tell it is now stale, and something must start the replacement request; deleting the cache entry alone does neither.

Read the lesson →
Major

A paywall bypass also skips the downgrade

A platform flag that skips an entitlement check usually also skips the code that revokes the entitlement: a bypass added to “select” silently disabled “reset on downgrade”, so stale paid state outlived the subscription.

Read the lesson →
Major

A full resync is not a cheap retry

A “reset sync” that clears clocks is a full re-download, not a cheap retry: trigger it only from a state that proves sync is stuck, never from mount effects, timers or diagnostics, and never twice at once.

Read the lesson →
Major

Clear scoped state before the reload starts

When the scope of a piece of client state changes, clear it before the async reload, not after: otherwise the UI spends the loading window showing, and submitting, values that belong to the previous scope.

Read the lesson →

The checks, with the lesson behind each

If a user changes their mind while an upload is still processing, can the old upload ever overwrite the new choice?+
An async result must prove it is still wanted before it writes: every path that changes the same field has to invalidate in-flight work, not just the next upload.
After an admin action changes what the server returns, does your app refresh every cached store that depends on it, or only the one you edited?+
When a server-side change filters several resources, re-fetch every client store that caches one of them, not just the store for the thing you changed.
Do any buttons in your app act on state that is updated through a debounce or throttle?+
A store field that mirrors an editor through a debounce is fine for display but wrong for actions; read the source of truth at the moment of the action, and re-check "where am I?" after every await.
If a user switches account while a request is in flight, can that response end up in the new account's local cache?+
In a persisted client cache, every async write needs two guards captured before the await: whose data it is (identity scope) and whether something newer was written meanwhile (a version or mutation counter), because timestamps alone can tie.
If the refresh after a successful save fails, does your UI undo the save or show it as an error?+
Put only the server call inside the rollback block; once the server has accepted a mutation, a failed refresh is a logging problem, not a reason to undo the user's change.
Does any server-rendered page write into a global client store (Zustand, Redux, a module variable) while rendering?+
A client store that is a module singleton becomes shared state across users the moment it runs on a server; pass server-loaded data down per request (context or props), never by writing it into the global store during render.
If a websocket event clears a cached value while a fetch for it is running, can the old response overwrite the new value?+
When a push event invalidates a cache, every request already in flight must be able to tell it is now stale, and something must start the replacement request; deleting the cache entry alone does neither.
When a paid plan expires, does every persisted "pro" choice in your app reset, on every platform?+
A platform flag that skips an entitlement check usually also skips the code that revokes the entitlement: a bypass added to "select" silently disabled "reset on downgrade", so stale paid state outlived the subscription.
Can you list every place your app forces a full resync, and does each one fire only when sync is provably stuck?+
A "reset sync" that clears clocks is a full re-download, not a cheap retry: trigger it only from a state that proves sync is stuck, never from mount effects, timers or diagnostics, and never twice at once.
If a user switches workspace and acts before the new data arrives, can your app submit a value from the old workspace?+
When the scope of a piece of client state changes, clear it before the async reload, not after: otherwise the UI spends the loading window showing, and submitting, values that belong to the previous scope.

More checks from the same reviews

Yes/no questions from other reviews on this theme. Each one is quick to answer for your own app.

  1. Does your app store anything per search term or filter in the browser, and what deletes it?
  2. Does any useEffect in your app call setState on an object that is also in its own dependency list?
  3. In your input or sync handlers, is any “in progress” state reset before the code has decided it will finish the job?
  4. Do your “empty state” and “count” badges come from the same filtered list the page renders?
  5. If your page reads a filter or tab from the URL, can the user still change it after landing on a deep link?

Want a senior reviewer to run these checks on your code? Is your vibe-coded MVP production-ready? →

Want these checked on your code?

We run reviews like these on real codebases. Tell us what you are building and we confirm scope before anything starts.

— Get in touch
hi@invental.co ↗
— Or
— What to include

A link or short description of the code or system, what you want checked, your stack, and when you need the answer. No repository access is needed until scope is agreed.

— Or leave a note
We reply within one business day.