Diagnosing MobX Side‑Effect Choices: reaction vs autorun in React
A diagnostic guide to help you decide between MobX reaction and autorun for React side‑effects, with checks, fixes, and escalation steps.
21 Aug 2025, 17:31 UTC

Recognizable Condition
When adding side‑effects (e.g., data fetching, logging, subscriptions) to a React component that uses MobX observables, you notice one of the following:
- The effect runs far more often than expected, causing performance spikes or unnecessary network calls.
- The effect never runs, even though the observable it depends on has changed.
- The effect triggers a render loop, leading to infinite updates or warnings about state changes during render.
These symptoms often stem from picking the wrong MobX tracking primitive for the side‑effect.
Cause & Diagnostic Table
| Symptom | Likely Cause | Why It Happens |
|---|---|---|
| Effect runs too frequently | Using autorun when the effect reads many observables |
Autorun re‑executes whenever any observable accessed during its execution changes, even if the change is irrelevant to the intended logic. |
| Effect never runs | Using reaction with an incomplete dependency list |
Reaction only tracks observables returned by the dependency function; if an observable is accessed indirectly (e.g., via a getter or computed not listed), it is ignored. |
| Infinite render loop | Placing autorun (or reaction) inside the component render phase |
The side‑effect may update state, causing another render, which re‑executes the effect, creating a cycle. |
Step‑by‑Step Diagnostic Checks
-
Identify where the side‑effect is created
Search your component file for
autorunorreactioncalls. Note whether they are placed inside the component body, auseEffecthook, or a custom hook. -
Check the tracking mode
Run the application with MobX devtools enabled (
window.__MOBX_DEVTOOLS__ = true) and open the devtools panel. Observe the call count for each reaction/autorun. -
List the observables accessed
Temporarily add a
console.loginside the effect to print the values of any observables you read. Compare this list to the dependency function you passed toreaction(if applicable). -
Test disposal
If you are using
reaction, capture the disposer it returns. In a test harness (e.g., a unit test or a temporary button), call the disposer and then change the observables. Verify that the effect no longer logs. -
Enable strict mode
Set
MobX.configure({ enforceActions: 'observed', reactionRequiresObservable: true })(MobX 6+). Reload the app and watch the console for warnings about untracked accesses when usingreaction.
Fixes Tied to Findings
If the effect runs too frequently
- Replace
autorunwithreactionand explicitly list only the observables that should trigger the effect. - Example (run in a component file,
src/components/UserProfile.jsx):import { reaction } from 'mobx'; import { observer } from 'mobx-react-lite'; const UserProfile = observer(({ userStore }) => { // Only re‑run when the user's ID changes reaction( () => userStore.id, // dependency function (id) => { if (id) fetchUserDetails(id); } ); return <div>{userStore.name}</div>; }); export default UserProfile;Place this code inside the component body (outside render) so the reaction is set up once per mount.
- Dispose on unmount – store the disposer returned by
reactionin auseRefand call it in auseEffectcleanup.
If the effect never runs
- Verify the dependency function – ensure every observable you read inside the effect is returned (directly or via a computed) from the first argument of
reaction. - Example fix – suppose you accidentally read
userStore.fullName(a computed) but only listeduserStore.firstNameanduserStore.lastName:
// BAD
reaction(
() => [userStore.firstName, userStore.lastName],
() => console.log('name changed')
);
// GOOD
reaction(
() => userStore.fullName, // the computed you actually use
(fullName) => console.log(`Full name: ${fullName}`)
);
If you see an infinite render loop
- Move the side‑effect out of the render phase – place
autorunorreactioninside auseEffecthook (with an empty dependency array) or at the top level of a custom hook. - Example – moving an autorun that logs changes:
import { useEffect } from 'react';
import { autorun } from 'mobx';
const Logger = ({ store }) => {
useEffect(() => {
const disposer = autorun(() => {
console.log('store value:', store.value);
});
return disposer; // cleanup on unmount
}, [store]);
return null;
});
When to Escalate
If after applying the above checks the side‑effect still behaves unexpectedly:
- Check whether the observable is being modified inside an action that is not properly wrapped (MobX may batch changes). Use
actiondecorators orrunInActionto ensure transactions are flushed. - Inspect for derived observables that change asynchronously (e.g., promises resolved inside a computed). Such patterns can cause delayed reactions; consider using
whenorreactionwith a timeout. - Consult the MobX version release notes (e.g., 6.x vs 5.x) for breaking changes in tracking behavior.
At this point, create a minimal reproducible example and post it to the MobX GitHub discussions or Stack Overflow, including the observed call counts from devtools and the exact dependency list you used.
Limitations and Practical Verification
This guide assumes you are using MobX ≥ 6 with mobx-react-lite in a React 18 environment. Older versions may lack the reactionRequiresObservable flag or have different disposer semantics.
To verify that your fix works:
- Open the MobX devtools panel and reset the call counters.
- Perform the user action that should trigger the side‑effect (e.g., select a different user).
- Confirm that the counter increments exactly the number of times you expect (usually once per change).
- If you added a disposer, simulate unmounting the component and verify the counter stops increasing.
If the counters match expectations, the side‑effect is correctly scoped.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.