MobX Views That Won't Update: Diagnosing Broken Tracking
A stale MobX view usually means the read happened outside a tracked context, not that the mutation failed. Here is the check order and the fix for each finding.
04 Sept 2025, 12:05 UTC

Stale values after a mutation usually mean a broken tracking link
If a component wrapped in observer keeps rendering an old value after you change the store, the mutation probably succeeded. The failure is that nothing told the derivation to re-run. MobX tracks reads that happen inside a tracked context — an observer render, a computed, autorun, or reaction — and re-runs that context when a tracked observable changes. A read that happens anywhere else is invisible to the tracker.
Two signals separate this from a failed write: the new value appears after an unrelated state change or a forced re-render, and the store itself holds the correct value when you inspect it. Both point at tracking, not at the mutation.
Cause-to-check table
| What you see | Likely cause | First check |
|---|---|---|
| Observed component shows an old value; an unrelated state change or forced re-render shows the new one | The read happens outside a tracked context | Is the read inside an observer render, computed, autorun, or reaction? |
| Store is correct when inspected, component is stale, and the component receives a plain value as a prop | The observable was dereferenced in a non-observed parent or copied into local state | Search for const x = store.y and useState(store.y) outside tracked code |
isObservable(store.field) returns false | The object was never made observable | Does the constructor call makeObservable(this, ...) or makeAutoObservable(this)? |
| Console warns about changing an observable outside an action | The write is untracked, or it happens after an await | Wrap the write in action / runInAction, or use flow |
| Existing keys update; a newly added key never does | The object is not a Proxy-backed observable | Check configure({ useProxies }) and whether the key was declared up front |
Ordered checks
1. Confirm the component is observed and reads the same store instance
Wrap the component with observer from mobx-react-lite (or the Observer component for a subtree), and confirm the store you mutate is the store the component reads. A duplicated store instance — one created per render, or one per module import path — produces stale-looking UI with perfectly working observables. Run this in a dev build, from the component file:
import { observer } from "mobx-react-lite";
export const CartView = observer(({ store }: { store: CartStore }) => (
<ul>
{store.visibleItems.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
));
If only part of a large tree needs reactivity, wrap that part in Observer instead of re-rendering the whole parent.
2. Confirm the store was annotated
In MobX 6, a class store is not observable until the constructor calls makeObservable or makeAutoObservable. Decorators are opt-in in MobX 6 and still require that constructor call. Check from a dev console or a temporary test:
import { isObservable } from "mobx";
console.log(isObservable(cartStore.items), isObservable(cartStore, "visibleItems"));
If either is false, add the annotation call. makeAutoObservable(this) infers fields, getters, and methods; explicit makeObservable(this, { items: observable, visibleItems: computed, add: action }) is more predictable when inference guesses wrong.
3. Confirm the read happens during render or computed evaluation
Reads inside event handlers, useEffect callbacks, timers, and promise callbacks are not tracked. Two common shapes are a non-observed parent dereferencing the store and passing a plain value down, and snapshotting into local state:
// Broken: the parent is not an observer, so this read is not tracked
function App() {
const store = useStore();
const items = store.visibleItems;
return <CartView items={items} />;
}
// Also broken: a snapshot that never updates
const [items] = useState(store.visibleItems);
Fix by moving the read into an observed component or computed, or by passing the store itself and reading inside the observed child.
4. Confirm mutations run inside actions
With configure({ enforceActions: "observed" }), MobX warns when an observable is modified outside an action. The warning is the diagnostic — do not silence it by switching to "never". Post-await assignments are the usual offender, because the action boundary does not survive the await:
import { makeAutoObservable, runInAction } from "mobx";
class CartStore {
items: string[] = [];
constructor() { makeAutoObservable(this); }
async load(url: string) {
const res = await fetch(url);
const data = await res.json();
runInAction(() => { this.items = data.items; });
}
}
flow with generator functions is the alternative when you want the whole async body treated as one action.
5. Confirm the derivation actually re-runs
Call trace() inside the derivation (the observer render or computed getter) and spy() at module scope to observe MobX events. trace reports which derivation is running and which observables it read. If it never fires on your mutation, the tracking link is missing; if it fires but the DOM does not change, the problem is downstream in React. Both are verbose and development-only — remove them before shipping.
Fixes tied to each finding
| Finding | Fix |
|---|---|
| Component is not observed | Wrap it in observer, or move the reactive read into a small Observer subtree |
| Store fields are not observable | Add makeObservable(this, ...) with observable/action/computed, or switch to makeAutoObservable |
| Value dereferenced before the tracked read | Read the observable inside the tracked function instead of passing a snapshot |
| Async write outside an action | Wrap post-await writes in runInAction, or convert the method to flow |
| New keys are not tracked | Declare fields up front, or enable Proxy-backed observables where the runtime supports them |
Version and environment assumptions
This guide assumes MobX 6 with mobx-react-lite and React. MobX 6 requires the explicit annotation call in class constructors; earlier majors auto-converted decorated fields, so advice written for MobX 5 may not apply. useObserver is the legacy hook path; observer and Observer are the current ones. On engines without Proxy — some React Native and older-engine configurations — MobX falls back to a non-proxy implementation where keys added after creation are not tracked. Verify against the versions in your lockfile rather than assuming.
How to verify the fix
- Build a scratch store with
makeAutoObservableand one observed component with a button that mutates. Confirm the component re-renders. - Deliberately copy the value into a local before the render and confirm the stale behavior reproduces. That gives you a known-bad baseline.
- Add
trace()to the derivation and repeat the mutation; then removeobserverand compare. - Run the same scenario with
enforceActions: "observed"so untracked writes surface as warnings instead of silent staleness.
Check the installed mobx and mobx-react-lite versions in the lockfile and read their release notes for annotation, decorator, and proxy changes before applying a fix copied from an older article.
When to escalate
- The component is observed and annotated,
traceshows the derivation re-running, and the DOM still does not change. SuspectReact.memowith an unstable comparison, list keys, or a duplicated store instance. - State lives in a module-level singleton shared across server-rendered requests, where one request's mutations leak into another's render.
- Observable arrays or maps are mutated in place by third-party code you do not control, so you cannot move the write into an action.
Limitations
React StrictMode double-invocation and Proxy availability can change what you observe, so isolate those factors before concluding the bug is in MobX. This guide does not cover MobX-State-Tree, which has its own action and snapshot rules. Major versions differ in decorator and annotation requirements, so treat the version notes above as items to confirm against your install, not as verified facts for it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.