Two‑Way Derived State in MobX 6 with Computed Setters
Learn how to give a MobX computed property a setter for true two‑way binding, with a form‑store example, limits on async setters, and common pitfalls to avoid.
07 Dec 2025, 21:41 UTC

Useful answer
MobX 6 lets you create a computed property that both reads and writes state. By giving a computed a setter, UI controls can update a derived value (e.g., a full name) and have the source observables (first name, last name) updated automatically, all without extra boilerplate.
How it works
makeAutoObservable scans a class and turns:
- plain properties into observables
- methods into actions
- getters into computed values
If a getter is paired with a setter on the same property name, MobX treats the pair as a computed with a setter. The setter runs inside an implicit action when invoked from a reaction or a MobX‑tracked context, so changes to the source observables are batched and notifications are deferred until the setter finishes.
Worked example: a form store
import { makeAutoObservable } from 'mobx';
class FormStore {
// source observables
firstName = '';
lastName = '';
// computed with getter and setter
get fullName() {
return `${this.firstName} ${this.lastName}`.trim();
}
set fullName(value) {
// split the incoming string and write back to sources
const [first, last] = value.trim().split(' ');
this.firstName = first ?? '';
this.lastName = last ?? '';
}
}
const store = makeAutoObservable(new FormStore());
// Example reaction to see the effect
import { autorun } from 'mobx';
autorun(() => console.log('sources:', store.firstName, store.lastName));
// Simulate UI binding: user types "Ada Lovelace"
store.fullName = 'Ada Lovelace';
// After the assignment, firstName === 'Ada' and lastName === 'Lovelace'
In a React component using mobx-react-lite you can bind an input directly to store.fullName:
import { observer } from 'mobx-react-lite';
import { useLocalObservable } from 'mobx-react';
const FullNameInput = observer(() => {
const store = useLocalObservable(() => new FormStore());
makeAutoObservable(store); // if not already done
return (
Full name:
store.fullName = e.target.value}
/>
First: {store.firstName} | Last: {store.lastName}
);
});
Each keystroke triggers the setter, which updates firstName and lastName. Because the setter runs inside an action, the component re‑renders only once after the full mutation, avoiding intermediate renders.
Limits and common mistakes
Synchronous only
A computed setter must finish synchronously. You cannot await inside it. If you need asynchronous validation or a server round‑trip, move that logic to a separate action:
class FormStore {
firstName = '';
lastName = '';
get fullName() { return `${this.firstName} ${this.lastName}`.trim(); }
set fullName(v) {
const [f, l] = v.trim().split(' ');
this.firstName = f;
this.lastName = l ?? '';
// delegate async work to an action
this.validateName();
}
async validateName() {
// pretend to call an API
await fakeApiCheck(this.firstName, this.lastName);
// set another observable if needed
}
}
Action wrapping for direct calls
When you call the setter from plain JavaScript (e.g., an event handler that isn’t observed by MobX), the setter still runs, but it is not automatically wrapped in an action. This can cause intermediate state warnings in development mode. Wrap the call with runInAction or place the call inside an action method:
import { runInAction } from 'mobx';
function handleSubmit() {
// unsafe: may trigger intermediate warnings
// store.fullName = 'John Doe';
// safe: batch the mutation
runInAction(() => {
store.fullName = 'John Doe';
});
}
Observable detection
makeAutoObservable makes all own properties observable by default. To keep a property non‑observable, either:
- declare it before the call and set
{ prop: false }in the options object, or - use
makeObservablewith explicit annotations.
class FormStore {
clientId = Math.random(); // should stay plain
firstName = '';
lastName = '';
// …
}
makeAutoObservable(new FormStore(), {
clientId: false // keep clientId non‑observable
});
Getter/setter syntax
Arrow functions break MobX’s heuristic for detecting computeds. Always use method syntax or explicit get/set keywords:
// ❌ wrong
fullName = () => `${this.firstName} ${this.lastName}`;
// ✅ correct
get fullName() { return `${this.firstName} ${this.lastName}`; }
set fullName(v) { /* … */ }
Practical verification
- Create a fresh project:
npm init -y && npm i mobx mobx-react-lite react react-dom. - Add the
FormStoreclass above and the React component. - Render the component and open the browser console.
- Type into the input; observe that the console logs from an
autorun onfirstNameandlastNameupdate only after each full mutation, not on every keystroke intermediate state. - Replace the direct setter call in an event handler with a raw assignment and notice the warning about missing action wrapping in dev mode.
- Try to add an
awaitinside the setter; the code will fail to compile or throw a runtime error, confirming the synchronous‑only rule.
These steps confirm that the computed setter behaves as a two‑way binding while respecting MobX’s action batching and synchronous constraints.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.