Diagnosing MobX Observer Components That Fail to Re-Render
A step-by-step diagnostic guide for MobX 6.x and React 18: why an observer-wrapped component stays frozen when the store updates, and how to fix missing observables, strict-mode violations, and version mismatches.
16 Jul 2025, 13:58 UTC

The Symptom: Observer Applied, but the UI Does Not Update
You wrapped a function component with observer from mobx-react-lite, the store value changes when you click a button, but the rendered output stays on the old number. No error appears in the console, and the component seems completely disconnected from the state it reads. This pattern usually means the reactive chain between the store and the component is broken at exactly one link.
The useful takeaway: MobX only re-renders a component when it reads an observable property during render, the component is wrapped in observer, and the mutation happens inside an action when strict mode is enabled. If any of those three conditions is missing, the update is silent.
Cause and Diagnostic Table
| Observable Symptom | Likely Cause | Fast Diagnostic |
|---|---|---|
| Component never re-renders, even on first store change | Missing or misplaced observer wrapper | Check the export statement and confirm the component reading the store is the one wrapped |
| Store methods run, but UI ignores them | Store properties are plain, not observable | Inspect the store constructor or object literal for makeAutoObservable / makeObservable |
Updates fail silently when enforceActions is on | Mutation outside an action boundary | Look for direct assignments like store.value = x outside methods or runInAction |
| Parent updates, child does not | Non-reactive reference passed as prop | Check if the child destructures the value or receives a plain snapshot |
| Warnings about duplicate or incompatible observers | Version mismatch between MobX bindings | Check package.json for both mobx-react and mobx-react-lite |
Ordered Checks
-
Confirm the observer wrapper is on the correct component
The component that reads the observable during render must be the one wrapped. Wrapping a parent is not enough if the parent passes a primitive down as a plain prop. Check the export:
// Correct export default observer(MyComponent); // Incorrect: parent wrapped, child reads store const Parent = observer(() => <Child value={store.count} />);If only
Childreadsstore.count, wrapChildinstead, or pass the store object itself and readstore.countinside the wrapped child. -
Verify the store actually creates observables
MobX does not track plain object properties automatically. The store must call
makeAutoObservable(or the lower-levelmakeObservable) so that MobX can instrument the fields. For a class store, this happens in the constructor:import { makeAutoObservable } from 'mobx'; class Store { count = 0; constructor() { makeAutoObservable(this); } increment() { this.count++; } }For a plain object store, wrap the object literal itself. In TypeScript projects with strict mode, give computed values explicit return types;
makeAutoObservablerelies on annotations to distinguish computed values from actions. -
Check strict-mode action boundaries
If you enabled
configure({ enforceActions: 'always' }), every mutation must occur inside an action—a function that MobX treats as a single atomic update. A direct assignment in an event handler oruseEffectwill be blocked, and depending on the MobX version, the failure may be silent or produce a console warning. Wrap the mutation:import { runInAction } from 'mobx'; // Outside a store method runInAction(() => { store.count = 5; });Prefer defining mutating logic as store methods so the action boundary is implicit and easier to audit.
-
Inspect how the component consumes the observable
Destructuring an observable value in render breaks reactivity because the component reads a plain number, not the observable box:
// Breaks reactivity const { count } = store; return <p>{count}</p>; // Preserves reactivity return <p>{store.count}</p>;The same applies to values captured inside
useEffector event handlers without an observer boundary. If a helper function reads the store, the component calling that helper must still be wrapped inobserver. -
Audit installed MobX bindings
MobX 6.x pairs with
mobx-react-lite3.x for function components in React 18. Installing bothmobx-react(v7) andmobx-react-lite(v3) can produce two differentobserverimplementations in the same tree, leading to components that subscribe under one system but render under another. Checkpackage.jsonand remove the binding you do not need.
Fixes Tied to Findings
- Missing observer: Move the
observerwrapper to the component that directly reads the observable property during render. If multiple siblings read the same store, wrap each one. - Non-observable store: Add
makeAutoObservable(this)to the class constructor, or wrap the plain object withmakeAutoObservable({ ... }). Re-run the failing interaction; the component should now re-render on mutation. - Strict-mode violation: Convert direct assignments into store methods, or wrap them in
runInAction. After the fix, the console warning (if any) should disappear and the UI should update. - Non-reactive reference: Stop destructuring observables in render. Read
store.propertyinline, or pass the observable object itself to child components and wrap those children withobserver. - Version mismatch: Standardize on
mobx-react-litefor function components. Removemobx-reactunless you specifically need class-component support or the v7 provider APIs.
A Working Minimal Setup
The following configuration is the baseline that should update correctly. It assumes MobX 6.x, mobx-react-lite 3.x, React 18, and TypeScript.
// store.ts
import { makeAutoObservable, configure } from 'mobx';
configure({ enforceActions: 'always' });
export class CounterStore {
count = 0;
constructor() {
makeAutoObservable(this);
}
increment() {
this.count++;
}
reset() {
this.count = 0;
}
}
export const counterStore = new CounterStore();
// Counter.tsx
import { observer } from 'mobx-react-lite';
import { counterStore } from './store';
const Counter = observer(() => {
return (
<div>
<p>Count: {counterStore.count}</p>
<button onClick={() => counterStore.increment()}>Increment</button>
</div>
);
});
export default Counter;
In this setup, increment is an action because it is a method on an auto-observable class. The component reads counterStore.count directly during render, and the observer wrapper subscribes to that specific property. If this baseline works but your application does not, diff your store and component against these two files.
When to Escalate
Escalate the investigation when the ordered checks above all pass but the component still fails to update:
- React Server Components: If you are using Next.js App Router or React Server Components, confirm the component is marked with
'use client'. MobX observables are client-side state and will not hydrate correctly in a server component. - Concurrent features: If the failure only appears inside
Suspenseboundaries or duringstartTransition, verify you are on MobX 6.4 or later, which contains fixes for concurrent rendering interactions. - Custom higher-order components: If a custom HOC or hook wraps the
observercall, ensure it returns the wrapped component rather than the original. A swallowedobserverHOC produces exactly the silent no-update behavior described here. - Class components:
mobx-react-litesupports function components only. If the failing component is a class component, switch toobserverfrommobx-react.
Limitations and Practical Verification
This guide targets MobX 6.x with React 18 and TypeScript. MobX 4 and 5 use different APIs such as decorate and extendObservable, and the mobx-react v6 package is not compatible with MobX 6. If your project uses decorators, confirm your Babel or TypeScript configuration supports them before relying on makeObservable annotations.
To verify a fix without guessing, add a temporary console.log as the first line of the component body. Trigger the store mutation. If the log fires, the observer subscription is working and the issue was elsewhere. If the log does not fire, the component is not subscribed, and you should re-check the observer wrapper and the observable definition. Remove the log after confirming.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.