Mini MobXLoading saved progress…

Mini MobX

MobX-style reactivity turns a plain object into an observable graph: reading one of its properties inside a reaction subscribes that reaction to the property, and writing a new value re-runs every reaction that read it — automatically, with no manual subscriptions and no dependency arrays. You never list what a function depends on; the library learns it by watching which properties the function actually touches while it runs. Build a tiny version of that engine with three pieces: observable, autorun, and computed.

observable(target) wraps a plain object in a Proxy so every read is tracked and every write notifies the readers. autorun(fn) runs fn right away, remembers which observable properties it read, and re-runs it whenever any of those change; it returns a dispose() that stops the tracking. computed(fn) is a memoized derived value: it recomputes only when a dependency changed, and it is itself a dependency for any reaction that reads it.

Signature

const miniMobx: {
  // Wrap a plain object; reads are tracked, writes trigger reactions.
  observable<T extends object>(target: T): T;

  // Run fn now and again on every change to what it read.
  // Returns dispose() to stop re-running it.
  autorun(fn: () => void): () => void;

  // A memoized derived value; get() reads it (and tracks it).
  computed<T>(fn: () => T): { get(): T };
};

Examples

const { observable, autorun } = miniMobx;

const state = observable({ first: 'Ada', last: 'Lovelace' });
const log = [];

// Runs once now (reads first + last), then again on any change to them.
autorun(() => log.push(state.first + ' ' + state.last));
// log: ['Ada Lovelace']

state.last = 'L.';       // re-runs → log: ['Ada Lovelace', 'Ada L.']
state.last = 'L.';       // same value → no re-run
state.middle = 'K.';     // never read → no re-run
const { observable, computed } = miniMobx;

const cart = observable({ price: 10, qty: 2 });
let runs = 0;
const total = computed(() => {
  runs++;
  return cart.price * cart.qty;
});

total.get(); // 20   (computed once, runs === 1)
total.get(); // 20   (cached,       runs === 1)
cart.qty = 3;
total.get(); // 30   (recomputed,   runs === 2)

Notes

  • Reads register, writes trigger — a property read inside a running reaction subscribes it to that property; a write to a new value re-runs every subscribed reaction.
  • Same value, no work — a write is only a change when the new value differs from the old one by Object.is; writing the identical value triggers nothing.
  • Dependencies are re-collected every run — after each run a reaction forgets its old dependencies and learns fresh ones, so a property it stops reading (an if branch it no longer takes) stops triggering it.
  • computed is lazy and memoized — it recomputes only when one of the properties it read changed; reading it otherwise returns the cached value, and two readers share a single computation.
  • Do not worry about — deep or nested observability, arrays and Map/Set, batching many writes into one flush, or the in/delete operators. Observe own properties one level deep and re-run synchronously on each write.

FAQ

How does MobX know what an autorun depends on without a dependency array?
It tracks reads at runtime. Before running your function it marks itself as the active reaction; every observable property read while it runs registers that reaction as a subscriber. The dependency list is whatever the function actually touched this run, re-collected on every run.
Why does computed only recompute when a dependency changes?
A computed caches its last value and a dirty flag. Writing to one of the properties it read flips the flag; the next get() recomputes once and caches again. Reads in between return the cache, and two reactions reading the same computed share that single computation.
What does the Proxy actually intercept?
The get trap runs on every property read and registers a dependency on the current reaction; the set trap runs on every write and, if the value actually changed, re-runs every reaction that read that property. That get-tracks / set-triggers pair is the whole engine.
Loading editor…