Articles
MobX Interview Questions: Observable State, Actions, and Lost Reactivity
Practice MobX interview questions with tracked-read traces, async action boundaries, observer integration, and concrete fixes for stale React interfaces.
A MobX interview can expose a store that changes correctly while the screen stays stale. The useful answer is to locate the observable read, identify the function that tracked it, and explain which later mutation should invalidate that dependency. Adding another observer wrapper without that trace can leave the real defect untouched.
This article separates official facts, linked to MobX documentation, from original practice exercises and engineering inference. It does not use candidate reports to claim that a particular employer asks these questions.
What actually creates a MobX dependency?
Official fact: MobX records observable property accesses made synchronously inside a tracked function. An observer component's render is one such function; copying a primitive before entering that function does not establish a subscription to its source. The documentation's reactivity examples explain this distinction.
Original exercise: Consider a profile badge whose user can be replaced during login:
const store = observable({ user: { name: "Ada" } });
const capturedUser = store.user;
const capturedName = store.user.name;
const Badge = observer(() => <span>{store.user.name}</span>);
const OldUserBadge = observer(() => <span>{capturedUser.name}</span>);
const FrozenBadge = observer(() => <span>{capturedName}</span>);
Assume these three components mount and no unrelated React update forces a render. Predict two separate changes: first rename the existing user, then replace store.user with a new object.
Badge reads through the store each time. OldUserBadge reads the original observable object's name but never reads the store's user property during render. FrozenBadge renders an ordinary string. This is why “destructuring breaks MobX” is an incomplete explanation: destructuring inside the tracked render can work; the location and timing of the read determine the result.
| Render expression | Existing user's name changes | Store's user reference changes |
|---|---|---|
store.user.name | Relevant dependency changes | Relevant dependency changes |
capturedUser.name | Original object's dependency changes | No dependency on the store reference |
capturedName | No observable read | No observable read |
The interview answer should name both the object and the property. “It watches the user” hides the distinction between watching a replaceable reference and watching one field on a captured object.
A useful follow-up is to mutate the old captured user after replacement. OldUserBadge can still react to that obsolete object. The screen is reactive, but reactive to the wrong owner. One successful update does not prove that the dependency graph is correct.
How do observable state, actions, and computed values differ?
Official fact: makeAutoObservable infers annotations for eligible members: ordinary fields become observable, getters become computed, and methods receive automatic action behavior. Explicit annotations and overrides remain available. Its limitations, including inheritance constraints, are described in observable state.
Original exercise: A cart stores quantities and prices. Its total should be derived from those inputs, while changing a quantity is a command. Keeping both the line items and a separately assigned total creates two mutable representations of the same fact.
Ask the interviewer whether prices are fixed locally or fetched remotely before choosing the model. If a price comes from the server, the stored server response is state; summing current prices remains a derivation. A computed total should not secretly trigger a network request each time a consumer reads it.
Official fact: computed values cache derivations while observed, track their inputs, and can suspend when unused. They should avoid side effects. Reading a computed outside an observing context does not promise a permanently retained cache. See computed values.
Engineering inference: choose one owner for mutable business facts and derive presentation facts from it. In a cart review, I would ask whether a discount is an input, a server decision, or a locally derived display value. Those three meanings need different lifetimes. The library cannot choose the domain contract for you.
An explicit action also has a different role from a getter. Do not mark every helper as an action simply because it belongs to a store. That can obscure which functions derive values and which commands intentionally change them.
Why does an action not cover the whole async method?
Official fact: an action batches synchronous changes until its outermost boundary finishes. A continuation after await runs later and needs its own action boundary when it mutates observable state. MobX documents runInAction, wrapped callbacks, and generator-based flow as approaches in asynchronous actions.
Original exercise: A search store starts request A, then request B. B completes first. Simply wrapping both completions in runInAction makes their updates properly grouped, but does not stop A from overwriting B's newer result.
class SearchStore {
query = "";
results = [];
phase = "idle";
requestId = 0;
constructor() {
makeAutoObservable(this, {}, { autoBind: true });
}
async search(query) {
const id = ++this.requestId;
this.query = query;
this.phase = "loading";
try {
const results = await fetchResults(query);
runInAction(() => {
if (id !== this.requestId) return;
this.results = results;
this.phase = "ready";
});
} catch (error) {
runInAction(() => {
if (id !== this.requestId) return;
this.phase = "error";
});
}
}
}
fetchResults is an application dependency, not a MobX API. Imports, the component, and transport cancellation are omitted to keep the ordering decision visible.
The request identifier establishes the application's latest-request policy. The action establishes the state-update boundary. Neither one replaces the other. With A numbered 1 and B numbered 2, B sets ready results. A's success or failure sees the mismatched identifier and makes no change.
If the desired behavior is to show results from every request, this guard would be wrong. State the product requirement before presenting it as a universal repair. Cancellation can reduce unnecessary work, but stale completion checks are still useful when cancellation is unavailable or too late.
When does React lose the observable read?
Official fact: observer subscribes to observables used during render. Passing an observable object to a child does not make an ordinary child observe its fields. At a component boundary that expects plain data, the integration guidance recommends passing the required plain values or converting the relevant data. See React integration.
Original exercise: An observer parent passes store.rows to a third-party table, but the table reads each row's selected field internally. The parent only accesses the array reference. Toggling a row field may not cause the parent to produce new props, and the table may also depend on reference equality.
A focused adapter can read exactly the needed fields during the observer parent's render:
const Rows = observer(({ store }) => {
const rows = store.rows.map(row => ({
id: row.id,
label: row.label,
selected: row.selected
}));
return <PlainTable rows={rows} />;
});
This is an original boundary adapter, not a prescription to clone the whole store on every render. Its purpose is to establish tracked reads and provide the plain table with fresh representation data. Check the table's documented update contract before selecting a larger conversion.
Explain the cost as well: mapping all rows repeatedly may be unnecessary for a large grid. An observer row component is another option if the table supports one. Choose the boundary that matches the consumer rather than treating a deep clone as the first debugging step.
Is an unbound method a reactivity failure?
Original exercise: A button receives onClick={store.increment}. The method expects this to be the store, but invocation as a callback can lose that receiver. The state may never change, which makes the resulting stale display look like a subscription problem.
Separate these questions: did the callback run, did it mutate the intended store, and did a tracked dependency change? Inspecting only the render skips the first two.
Binding with action.bound, autoBind, or an appropriate wrapper preserves the receiver. An arrow method captures it lexically. These approaches concern invocation; they do not make fields read outside render become tracked. Likewise, replacing a local JavaScript variable with another store does not make that variable observable automatically.
Engineering inference: classify the bug before choosing the repair. A lost receiver needs binding. A captured primitive needs a correctly placed read. A stale async result needs an ordering policy. A third-party consumer needs an explicit data boundary. Using the same repair for all four creates fragile code.
How would you prove the repair?
For the badge exercise, assert two operations independently: mutate the original name, then replace the user reference. A test that only renames the original object cannot detect the captured-object defect. Add an assertion that changes to the abandoned object no longer update the main badge.
For the search exercise, control promise resolution instead of waiting for real network timing. Resolve B before A and verify the final query, results, and phase belong to B. Reject A after B succeeds and verify the old failure does not turn the current screen into an error.
For the table adapter, change one selected flag while retaining the array reference. Verify the consumer receives the changed selection through its actual rendering contract. This catches the gap between an observable mutation and a plain-data component's update mechanism.
MobX's reactivity analysis tools include trace and dependency inspection. Engineering inference: use them to answer a specific question, such as whether this render read the replaceable user reference. A large console dump is less useful than a small dependency list tied to one expected transition.
Dispose reactions created by a test or component lifecycle. Otherwise later tests can react to an old store and produce confusing output. Test isolation is part of proving ownership, not merely cleanup etiquette.
What should a reaction own?
Original exercise: A store exposes a selected account ID, and an application reaction writes it into local storage. The selection is state, the storage write is a side effect, and the function that decides whether an account is selectable remains a derivation. Moving all three into a computed getter makes an ordinary read unexpectedly write external state.
Engineering inference: locate the side effect at an explicit owner with a lifecycle. Ask who creates the reaction, when it is disposed, and whether it should run immediately or only after a change. Replacing the whole store should also replace that subscription deliberately. A reaction left attached to a discarded store can keep writing old values after the current screen appears correct.
In an interview, explain this with one selection change and one store replacement. The expected storage writes should follow the application contract, not simply the number of React renders.
Practice the boundaries in an interview
Explain which dependency, callback, or request boundary you would inspect in each.
| Question | MobX-focused follow-up |
|---|---|
| Debug and optimize React performance issues | Distinguish missing reads from excessive renders. |
| Build a Race-Free Memory Card Game in React | Identify state transitions that must stay together. |
| Build a React Typeahead with Async Suggestions and Keyboard Navigation | Resolve older and newer requests in reverse order. |
| Implement, Debug, and Optimize a React Table | Define the plain-data table boundary. |
| Implement React logic with TypeScript hooks | Separate lifecycle ownership from observable reads. |
For a focused rehearsal, open the typeahead exercise and describe an out-of-order completion before writing the store. A strong explanation identifies the observed properties, the owner of each command, and the transition that must be rejected.