I wrote MobX from scratch in 150 lines. Here's every bug I hit
A tiny MobX clone built on Proxy: observable, autorun, computed and action. Stale subscriptions, a Set that never stops iterating, a computed that never updates, and five ways batching broke.
I'm preparing for senior React Native interviews, and one kind of task keeps showing up: "write your own state manager". Redux is the usual target. I picked MobX instead, because I've used it for years and realized I couldn't explain what happens between store.count++ and the re-render.
The finished version is about 150 lines: observable, autorun, computed, action, and a React hook. Getting there took a lot more than 150 lines of wrong code. This post goes through the bugs in the order I made them. Every output below comes from running the code.
The core: track on read, trigger on write
MobX doesn't ask you to declare dependencies. A reaction runs once, MobX watches which fields it reads, and from then on it reruns only when one of those fields changes.
You need two pieces for that. A global variable that says which reaction is running right now, and a Proxy that records reads and announces writes:
type Reaction = (() => void) & { deps: Set<Set<Reaction>> }
let currentReaction: Reaction | null = null
export const observable = <T extends object>(object: T): T => {
const reactionsByKey = new Map<string | symbol, Set<Reaction>>()
const track = (key: string | symbol) => {
if (!currentReaction) return
let reactions = reactionsByKey.get(key)
if (!reactions) {
reactions = new Set()
reactionsByKey.set(key, reactions)
}
reactions.add(currentReaction)
currentReaction.deps.add(reactions)
}
const trigger = (key: string | symbol) => {
const reactions = reactionsByKey.get(key)
if (!reactions) return
[...reactions].forEach((reaction) => reaction())
}
return new Proxy(object, {
get(obj, key, receiver) {
track(key)
return Reflect.get(obj, key, receiver)
},
set(obj, key, value, receiver) {
if (Object.is(Reflect.get(obj, key, receiver), value)) return true
const result = Reflect.set(obj, key, value, receiver)
if (result) trigger(key)
return result
},
})
}Every key has a Set of reactions that read it. autorun puts itself into currentReaction, calls your function, and every get along the way adds it to the right Sets.
A few Proxy details matter here. The set trap has to return true, because a falsy result throws a TypeError in strict mode. Reflect.set already returns a boolean, so passing it through is enough. Object.is skips writes that don't change anything, and unlike === it treats NaN as equal to NaN, so writing NaN over NaN doesn't wake anyone up. The receiver argument decides what this is inside getters and setters on the object. It confused me enough that it gets its own post.
Almost every line above was a bug fix at some point.
Bug 1: a reaction that listens to fields it stopped reading
My first autorun just set currentReaction, called the function and cleared it. Dependencies only ever got added. Here's what that does to a conditional read:
const s = observable({ show: true, name: 'Bogdan' })
autorun(() => console.log('render:', s.show ? s.name : 'hidden'))
s.show = false
s.name = 'Ivan'render: Bogdan
render: hidden
render: hidden
After show became false, the function no longer read name. It still reran when name changed, because it was still sitting in the Set for name from the first run. In a React app this is a component that re-renders because of data it doesn't display.
Dependencies have to be rebuilt on every run. Before calling the function, remove the reaction from every Set it's in, and let the run add it back where it still belongs. That's why each reaction keeps deps, a Set of the Sets that contain it. Without the link back, there's no way to find those Sets:
const cleanup = () => {
reaction.deps.forEach((set) => set.delete(reaction))
reaction.deps.clear()
}With cleanup the same code prints two lines.
Bug 2: the Set that never ends
Cleanup fixed bug 1 and broke something else. My trigger iterated the Set directly:
reactions.forEach((reaction) => reaction())const s = observable({ count: 0 })
autorun(() => console.log('count:', s.count))
s.count = 1count: 0
count: 1
count: 1
count: 1
count: 1
...
It never stopped. I had a guard that threw after a few runs, otherwise the tab would just hang.
Set.prototype.forEach visits entries that are added while it's iterating. If you delete an entry and add it back, it goes to the end of the Set and gets visited again. That's exactly what a reaction does: cleanup deletes it from the Set for count, then the function reads count and track adds it back. forEach sees a "new" entry and calls it, and the same thing happens again.
You can see it without any reactivity:
const set = new Set(['x'])
let visits = 0
set.forEach((v) => {
if (++visits > 3) return
set.delete(v)
set.add(v)
})
visits // 4The fix is to iterate over a copy, [...reactions]. Vue 3 copied the Set in triggerEffects in the same way, until 3.5 replaced Sets with linked lists. I hit this bug a second time later, when I wrote batching.
Bug 3: a nested autorun steals tracking from its parent
If an autorun creates another autorun, the inner one overwrites currentReaction. When it finishes and resets it to null, the outer one keeps running with no reaction to attach reads to:
const s = observable({ a: 1, c: 1 })
autorun(() => {
autorun(() => console.log('inner a:', s.a))
console.log('outer c:', s.c)
})
s.c = 2Nothing happens on s.c = 2. The outer reaction read c while currentReaction was null, so it never subscribed.
The fix is a stack, but you don't need an array for it. Each run remembers the previous reaction and puts it back at the end, in finally so that an exception in the function doesn't leave the wrong reaction behind:
const previousReaction = currentReaction
try {
currentReaction = reaction
cleanup()
fn()
} finally {
currentReaction = previousReaction
}After this change, s.c = 2 prints outer c: 2.
Bug 4: dispose that didn't dispose
autorun returns a function that stops it. It needs two things: cleanup() to remove the reaction from all Sets, and an isDisposed flag. The flag covers the case where trigger already copied the Set and the reaction is in that copy when it gets disposed. Without the flag, it would run one more time after you stopped it.
Later I moved the shared code into a helper and passed the flag as an argument:
const makeReaction = (isDisposed: boolean, fn: () => void) => {
const reaction = () => {
if (isDisposed) return
// ...
}
}
export const autorun = (fn: () => void) => {
let isDisposed = false
const reaction = makeReaction(isDisposed, fn)
// ...
return () => { isDisposed = true }
}A boolean is passed by value. isDisposed = true in dispose changes the variable in autorun, and the helper still has its own false. The check has to read the variable through a closure, either by keeping it in the scope where the reaction is created or by passing a getter.
Hooking it up to React
The first hook was useState plus useEffect: run an autorun in an effect and push the selected value into state. Two things went wrong.
I wrote setState(value). When the selected value is a function, React calls it as an updater instead of storing it. The safe form is setState(() => value).
The selector you pass changes on every render, because it's usually an inline arrow. The autorun is created once, so it would keep calling the selector from the first render. The latest selector goes into a ref, updated in useLayoutEffect, and the autorun reads selector.current.
The second version uses useSyncExternalStore, which React 18 added for exactly this, and which avoids tearing in concurrent rendering:
export const useObserver = <T,>(fn: () => T) => {
const selector = useRef(fn)
useLayoutEffect(() => {
selector.current = fn
}, [fn])
const subscribe = useCallback(
(onStoreChange: () => void) =>
autorun(() => {
selector.current()
onStoreChange()
}),
[]
)
return useSyncExternalStore(subscribe, fn)
}subscribe has to be stable. If it's a new function on each render, React unsubscribes and subscribes again every time. The autorun calls selector.current() only to subscribe to whatever it reads, and onStoreChange tells React to call getSnapshot again.
There's a known trap I left unsolved. If the selector returns a new object or array, for example store.items.filter(...), getSnapshot returns a new reference on every call. React warns that the result of getSnapshot should be cached and then re-renders in a loop. A real implementation memoizes the snapshot.
computed: a cache that knows when it's stale
computed is a derived value that is lazy and cached:
const total = computed(() => cart.price * cart.qty)
total.get() // computes
total.get() // returns the cached value
cart.qty = 3 // marks it stale, doesn't recompute
total.get() // recomputesIt should recompute only when a field it actually read has changed. Its function also has to be pure. It can only depend on observable fields. If it reads a plain variable or Date.now(), nothing will ever tell it the value changed.
Bug 5: isDirty started as false
My first get() was if (isDirty) { value = fn(); isDirty = false } with isDirty initialized to false. The first call returned undefined, and nothing ever set the flag to true. That one's easy to find.
Bug 6: subscribing the function that computes
Then I needed something to set isDirty = true when a dependency changed. I reused the autorun machinery: a reaction that runs fn with tracking, and get() calls it when the value is stale.
The problem is which function ends up in the Sets. During tracking, currentReaction was the evaluator itself. So when cart.qty changed, trigger called the evaluator directly, bypassing get() and its isDirty check:
const total = computed(() => cart.price * cart.qty)
total.get() // 200, evaluator calls: 1
cart.qty = 3 // evaluator calls: 2
total.get() // 200The value was recomputed eagerly when nobody asked for it. The result was thrown away, because trigger ignores return values. isDirty stayed false, so get() kept returning 200 from then on.
A computed needs two separate things. One is what trigger calls, and for a computed that function only marks it stale:
const markDirty = () => { isDirty = true }The other is the tracked run of fn that happens inside get(), with markDirty as the current reaction. That way the fields subscribe markDirty, and fn runs only when someone reads the value.
Bug 7: two functions called fn
At this point autorun and computed had the same block: save the previous reaction, set the current one, clean up, run, restore in finally. I moved it into a shared createReaction:
const createReaction = (fn: () => void) => {
const reaction = Object.assign(() => fn(), { deps: new Set() })
const cleanup = () => { /* ... */ }
const run = () => {
const previousReaction = currentReaction
try {
currentReaction = reaction
cleanup()
return fn()
} finally {
currentReaction = previousReaction
}
}
return { reaction, cleanup, run }
}Inside createReaction, fn is the reaction callback: markDirty for a computed, and for an autorun a callback that calls run(). The user's function lives in a different scope with the same name. So the computed's run() executed markDirty and returned undefined, and the autorun did callback → run → callback → run until Maximum call stack size exceeded.
The function to run has to come in as an argument:
const run = <T,>(work: () => T): T => {
const previousReaction = currentReaction
try {
currentReaction = reaction
cleanup()
return work()
} finally {
currentReaction = previousReaction
}
}The computed calls run(fn) with its formula, the autorun calls run(fn) with its body, and the parameter of createReaction gets a name that can't be confused with either, like onTrigger.
The autorun's callback calls run, which only exists after createReaction returns. This works:
const callback = () => {
if (isDisposed) return
run(fn)
}
const { reaction, cleanup, run } = createReaction(callback)
reaction()A closure captures the variable, not its value. callback is created before run exists, but it isn't called until reaction() on the last line, and by then run is initialized. Calling callback() before the destructuring would throw "Cannot access 'run' before initialization".
action: five ways batching broke
Every write triggers reactions right away, so two writes run an autorun twice, and the first run sees a half-updated state:
autorun(() => console.log(cart.price, cart.qty))
cart.price = 200 // 200 2
cart.qty = 5 // 200 5action should collect the writes and run each affected reaction once at the end. Values are still written immediately, so reading cart.price inside the action returns the new value. Only reactions wait.
Bug 8: a queue of Sets
My first version put the Set of reactions for each changed key into a queue. price and qty have separate Sets, and the same autorun is in both:
autorun: 100 2
autorun: 200 5
autorun: 200 5
The queue has to hold reactions. A Set<Reaction> deduplicates them on its own.
Bug 9: the queue was never cleared
The next action ran every reaction from the previous one again. clear() after the flush.
Bug 10: a nested action ended the batch early
action(() => {
action(() => { s.a = 2 })
s.b = 2
})The inner action set isBatching = false when it finished, so s.b = 2 ran reactions immediately. Each action has to remember the previous state of the flag, restore it, and flush only if it was the outermost one. MobX uses a counter, globalState.inBatch, and runs reactions when it drops back to zero. A saved boolean does the same job.
Bug 11: computed went stale inside an action
const total = computed(() => s.price * s.qty)
total.get()
action(() => {
s.qty = 3
console.log(total.get())
})total inside action: 200
total after action: 300
I had put the batching check into trigger: during an action, queue every reaction instead of calling it. That included markDirty. The computed didn't learn it was stale until the action ended, so get() inside the action returned the old value.
A computed doesn't need batching at all. Marking it stale is cheap and the recompute is lazy anyway. The question is where the decision belongs. You can teach trigger the difference between kinds of reactions, but then the Reaction type needs a flag. I moved the check into the autorun's callback instead, and trigger went back to calling everything:
const callback = () => {
if (isDisposed) return
if (isBatching) {
batchingDelayedReactions.add(reaction)
return
}
run(fn)
}MobX is built the same way. When a value changes, it calls onBecomeStale_ on every observer. A computed reacts by propagating "maybe changed" to its own observers. A reaction calls schedule_(), which pushes it onto globalState.pendingReactions, and the list runs when the batch ends.
Bug 12: flushing while still batching
With the check inside the reaction, the next run of my tests printed nothing after the action. The flush loop called each queued reaction while isBatching was still true, and each one put itself back into the queue and returned. The flag has to be reset before the flush.
Bug 13: an exception left the flag on
If the function passed to action threw, the code after it never ran, isBatching stayed true, and from then on every write in the app was queued and never flushed. Restoring the flag and flushing go into finally. The flush runs over a copy of the queue, for the same reason as bug 2.
The final version:
let isBatching = false
const batchingDelayedReactions = new Set<Reaction>()
export const action = (fn: () => void) => {
const previousBatching = isBatching
isBatching = true
try {
fn()
} finally {
isBatching = previousBatching
if (!previousBatching) {
const reactions = [...batchingDelayedReactions]
batchingDelayedReactions.clear()
reactions.forEach((reaction) => reaction())
}
}
}My tests cover four cases: two actions in a row, a nested action, a computed read inside an action, and an exception followed by a plain write. All four pass now. Before this version, each of them failed in a different way.
What's still missing
One gap is visible right away:
const total = computed(() => cart.price * cart.qty)
autorun(() => console.log('total:', total.get()))
cart.qty = 3This prints total: 200 once, and nothing after qty changes. Inside get() the current reaction is markDirty, so the autorun never subscribes to price or qty, and the computed has no observers of its own. To fix it, a computed has to act as an observable: track whoever calls get(), and notify them when it becomes stale.
Even with that fixed, MobX does a lot more. Its graph avoids glitches: when two computeds depend on the same field and an autorun reads both, the autorun runs once and never sees one updated and one stale. A computed with no observers stops listening to its own dependencies. There are observable Map and Set, async actions with flow, comparers, onBecomeObserved hooks, trace and spy. That's why the production build of MobX 7.1.3 is around 14.7 KB gzipped, against my 150 lines.
What I got out of it is that I now know where all of that comes from. In an interview, "MobX tracks reads through Proxy" is the short answer. The cleanup, the copy before iterating, the restore in finally, and the batching check in the reaction are what the follow-up questions are about.