Skip to content

Instantly share code, notes, and snippets.

@codeBelt
Created February 27, 2026 03:30
Show Gist options
  • Select an option

  • Save codeBelt/8601de78348522ae7429958dc5d26b40 to your computer and use it in GitHub Desktop.

Select an option

Save codeBelt/8601de78348522ae7429958dc5d26b40 to your computer and use it in GitHub Desktop.
MobX React Hook
import {describe, expect, it} from 'bun:test';
import {comparer, makeAutoObservable, reaction, toJS} from 'mobx';
/**
* Tests the subscription logic that useMobxSelector relies on (reaction + selector).
* Validates reactivity, no unwanted side effects when unrelated observables change,
* and proper disposal.
*
* The "toJS + comparer.structural" sections mirror the actual useMobxSelector
* implementation and prove that direct observable selectors (no manual spreading)
* work correctly.
*/
describe('useMobxSelector (reaction-based subscription)', () => {
type TestStore = {
a: number;
b: number;
setA: (n: number) => void;
setB: (n: number) => void;
};
function createTestStore(initialA = 0, initialB = 0) {
const store = {
a: initialA,
b: initialB,
setA(n: number) {
this.a = n;
},
setB(n: number) {
this.b = n;
},
};
makeAutoObservable(store);
return store as TestStore;
}
describe('reaction with selector', () => {
it('effect receives initial value when fireImmediately is true', () => {
const store = createTestStore(10, 20);
const effects: number[] = [];
const selector = () => store.a;
const dispose = reaction(selector, (value) => effects.push(value), {
fireImmediately: true,
});
expect(effects).toEqual([10]);
dispose();
});
it('effect is called when selected observable changes', () => {
const store = createTestStore(1, 2);
const effects: number[] = [];
const selector = () => store.a;
const dispose = reaction(selector, (value) => effects.push(value), {
fireImmediately: true,
});
store.setA(5);
expect(effects).toEqual([1, 5]);
dispose();
});
it('does NOT fire when unrelated observable changes (no unwanted side effects)', () => {
const store = createTestStore(1, 2);
const effects: number[] = [];
const selector = () => store.a;
const dispose = reaction(selector, (value) => effects.push(value), {
fireImmediately: true,
});
store.setB(99);
expect(effects).toEqual([1]);
dispose();
});
it('properly disposes: no effect after dispose', () => {
const store = createTestStore(1, 2);
const effects: number[] = [];
const selector = () => store.a;
const dispose = reaction(selector, (value) => effects.push(value), {
fireImmediately: true,
});
dispose();
store.setA(10);
expect(effects).toEqual([1]);
});
it('works with derived selector (multiple observables)', () => {
const store = createTestStore(3, 4);
const effects: number[] = [];
const selector = () => store.a + store.b;
const dispose = reaction(selector, (value) => effects.push(value), {
fireImmediately: true,
});
expect(effects).toEqual([7]);
store.setA(1);
expect(effects).toEqual([7, 5]);
store.setB(10);
expect(effects).toEqual([7, 5, 11]);
dispose();
});
});
describe('reaction with arrays', () => {
function createArrayStore() {
const store = {
items: [] as string[],
unrelated: 0,
addItem(item: string) {
this.items.push(item);
},
removeItem(index: number) {
this.items.splice(index, 1);
},
replaceItems(items: string[]) {
this.items = items;
},
setUnrelated(value: number) {
this.unrelated = value;
},
};
makeAutoObservable(store);
return store;
}
it('fires when an item is pushed to an observable array', () => {
const store = createArrayStore();
const effects: string[][] = [];
const dispose = reaction(
() => [...store.items],
(value) => effects.push(value),
{fireImmediately: true},
);
store.addItem('a');
expect(effects).toEqual([[], ['a']]);
store.addItem('b');
expect(effects).toEqual([[], ['a'], ['a', 'b']]);
dispose();
});
it('fires when an item is removed from an observable array', () => {
const store = createArrayStore();
store.replaceItems(['x', 'y', 'z']);
const effects: string[][] = [];
const dispose = reaction(
() => [...store.items],
(value) => effects.push(value),
{fireImmediately: true},
);
store.removeItem(1);
expect(effects).toEqual([
['x', 'y', 'z'],
['x', 'z'],
]);
dispose();
});
it('fires when the array is replaced entirely', () => {
const store = createArrayStore();
store.replaceItems(['old']);
const effects: string[][] = [];
const dispose = reaction(
() => [...store.items],
(value) => effects.push(value),
{fireImmediately: true},
);
store.replaceItems(['new1', 'new2']);
expect(effects).toEqual([['old'], ['new1', 'new2']]);
dispose();
});
it('does NOT fire when unrelated property changes (array selector)', () => {
const store = createArrayStore();
store.replaceItems(['a']);
const effects: string[][] = [];
const dispose = reaction(
() => [...store.items],
(value) => effects.push(value),
{fireImmediately: true},
);
store.setUnrelated(42);
expect(effects).toEqual([['a']]);
dispose();
});
it('selector returning array length fires only on length change', () => {
const store = createArrayStore();
const effects: number[] = [];
const dispose = reaction(
() => store.items.length,
(value) => effects.push(value),
{fireImmediately: true},
);
store.addItem('a');
store.addItem('b');
expect(effects).toEqual([0, 1, 2]);
dispose();
});
});
describe('reaction with objects', () => {
type User = {name: string; age: number};
function createObjectStore() {
const store = {
user: null as User | null,
settings: {theme: 'dark', locale: 'en'},
counter: 0,
setUser(user: User | null) {
this.user = user;
},
updateSettings(partial: Partial<{theme: string; locale: string}>) {
Object.assign(this.settings, partial);
},
replaceSettings(settings: {theme: string; locale: string}) {
this.settings = settings;
},
incrementCounter() {
this.counter++;
},
};
makeAutoObservable(store);
return store;
}
it('fires when a nullable object is set', () => {
const store = createObjectStore();
const effects: Array<User | null> = [];
const dispose = reaction(
() => store.user,
(value) => effects.push(value ? {...value} : null),
{fireImmediately: true},
);
store.setUser({name: 'Alice', age: 30});
expect(effects).toEqual([null, {name: 'Alice', age: 30}]);
dispose();
});
it('fires when a nested property of an observed object changes', () => {
const store = createObjectStore();
const effects: string[] = [];
const dispose = reaction(
() => store.settings.theme,
(value) => effects.push(value),
{fireImmediately: true},
);
store.updateSettings({theme: 'light'});
expect(effects).toEqual(['dark', 'light']);
dispose();
});
it('does NOT fire when an unread nested property changes', () => {
const store = createObjectStore();
const effects: string[] = [];
const dispose = reaction(
() => store.settings.theme,
(value) => effects.push(value),
{fireImmediately: true},
);
store.updateSettings({locale: 'fr'});
expect(effects).toEqual(['dark']);
dispose();
});
it('fires when the entire object reference is replaced', () => {
const store = createObjectStore();
const effects: string[] = [];
const dispose = reaction(
() => store.settings.theme,
(value) => effects.push(value),
{fireImmediately: true},
);
store.replaceSettings({theme: 'blue', locale: 'de'});
expect(effects).toEqual(['dark', 'blue']);
dispose();
});
it('does NOT fire when unrelated property changes (object selector)', () => {
const store = createObjectStore();
const effects: string[] = [];
const dispose = reaction(
() => store.settings.theme,
(value) => effects.push(value),
{fireImmediately: true},
);
store.incrementCounter();
expect(effects).toEqual(['dark']);
dispose();
});
it('selector returning a derived object fires on structural change', () => {
const store = createObjectStore();
const effects: Array<{theme: string; locale: string}> = [];
const dispose = reaction(
() => ({theme: store.settings.theme, locale: store.settings.locale}),
(value) => effects.push(value),
{fireImmediately: true},
);
store.updateSettings({theme: 'light'});
expect(effects).toEqual([
{theme: 'dark', locale: 'en'},
{theme: 'light', locale: 'en'},
]);
dispose();
});
});
/**
* Tests using toJS + comparer.structural — mirrors the actual useMobxSelector
* implementation. Selectors return observables DIRECTLY (no manual spreading).
*/
describe('toJS + comparer.structural with direct array selectors', () => {
function createArrayStore() {
const store = {
items: [] as string[],
unrelated: 0,
addItem(item: string) {
this.items.push(item);
},
removeItem(index: number) {
this.items.splice(index, 1);
},
replaceItems(items: string[]) {
this.items = items;
},
setUnrelated(value: number) {
this.unrelated = value;
},
};
makeAutoObservable(store);
return store;
}
it('fires when an item is pushed (direct selector, no spread)', () => {
const store = createArrayStore();
const effects: string[][] = [];
const dispose = reaction(
() => toJS(store.items),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.addItem('a');
expect(effects).toEqual([[], ['a']]);
store.addItem('b');
expect(effects).toEqual([[], ['a'], ['a', 'b']]);
dispose();
});
it('fires when an item is removed (direct selector, no spread)', () => {
const store = createArrayStore();
store.replaceItems(['x', 'y', 'z']);
const effects: string[][] = [];
const dispose = reaction(
() => toJS(store.items),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.removeItem(1);
expect(effects).toEqual([
['x', 'y', 'z'],
['x', 'z'],
]);
dispose();
});
it('fires when the array is replaced entirely (direct selector)', () => {
const store = createArrayStore();
store.replaceItems(['old']);
const effects: string[][] = [];
const dispose = reaction(
() => toJS(store.items),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.replaceItems(['new1', 'new2']);
expect(effects).toEqual([['old'], ['new1', 'new2']]);
dispose();
});
it('does NOT fire when unrelated property changes (direct array selector)', () => {
const store = createArrayStore();
store.replaceItems(['a']);
const effects: string[][] = [];
const dispose = reaction(
() => toJS(store.items),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.setUnrelated(42);
expect(effects).toEqual([['a']]);
dispose();
});
it('does NOT fire when same value is set (structural equality)', () => {
const store = createArrayStore();
store.replaceItems(['a', 'b']);
const effects: string[][] = [];
const dispose = reaction(
() => toJS(store.items),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
// Replace with structurally identical array — should NOT fire
store.replaceItems(['a', 'b']);
expect(effects).toEqual([['a', 'b']]);
dispose();
});
});
describe('toJS + comparer.structural with direct object selectors', () => {
type User = {name: string; age: number};
function createObjectStore() {
const store = {
user: null as User | null,
settings: {theme: 'dark', locale: 'en'},
counter: 0,
setUser(user: User | null) {
this.user = user;
},
updateSettings(partial: Partial<{theme: string; locale: string}>) {
Object.assign(this.settings, partial);
},
replaceSettings(settings: {theme: string; locale: string}) {
this.settings = settings;
},
incrementCounter() {
this.counter++;
},
};
makeAutoObservable(store);
return store;
}
it('fires when a nested property mutates (direct object selector)', () => {
const store = createObjectStore();
const effects: Array<{theme: string; locale: string}> = [];
const dispose = reaction(
() => toJS(store.settings),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.updateSettings({theme: 'light'});
expect(effects).toEqual([
{theme: 'dark', locale: 'en'},
{theme: 'light', locale: 'en'},
]);
dispose();
});
it('fires on partial update via Object.assign (direct object selector)', () => {
const store = createObjectStore();
const effects: Array<{theme: string; locale: string}> = [];
const dispose = reaction(
() => toJS(store.settings),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.updateSettings({locale: 'fr'});
expect(effects).toEqual([
{theme: 'dark', locale: 'en'},
{theme: 'dark', locale: 'fr'},
]);
dispose();
});
it('fires when nullable object is set (direct selector)', () => {
const store = createObjectStore();
const effects: Array<User | null> = [];
const dispose = reaction(
() => toJS(store.user),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.setUser({name: 'Alice', age: 30});
expect(effects).toEqual([null, {name: 'Alice', age: 30}]);
dispose();
});
it('does NOT fire when unrelated property changes (direct object selector)', () => {
const store = createObjectStore();
const effects: Array<{theme: string; locale: string}> = [];
const dispose = reaction(
() => toJS(store.settings),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.incrementCounter();
expect(effects).toEqual([{theme: 'dark', locale: 'en'}]);
dispose();
});
it('does NOT fire when same values are set (structural equality)', () => {
const store = createObjectStore();
const effects: Array<{theme: string; locale: string}> = [];
const dispose = reaction(
() => toJS(store.settings),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
// Replace with structurally identical object — should NOT fire
store.replaceSettings({theme: 'dark', locale: 'en'});
expect(effects).toEqual([{theme: 'dark', locale: 'en'}]);
dispose();
});
it('fires when entire object reference is replaced with different values', () => {
const store = createObjectStore();
const effects: Array<{theme: string; locale: string}> = [];
const dispose = reaction(
() => toJS(store.settings),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.replaceSettings({theme: 'blue', locale: 'de'});
expect(effects).toEqual([
{theme: 'dark', locale: 'en'},
{theme: 'blue', locale: 'de'},
]);
dispose();
});
});
describe('toJS + comparer.structural with primitives (no overhead)', () => {
function createPrimitiveStore() {
const store = {
count: 0,
name: 'initial',
setCount(value: number) {
this.count = value;
},
setName(value: string) {
this.name = value;
},
};
makeAutoObservable(store);
return store;
}
it('works with number selector (toJS is a no-op)', () => {
const store = createPrimitiveStore();
const effects: number[] = [];
const dispose = reaction(
() => toJS(store.count),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.setCount(5);
expect(effects).toEqual([0, 5]);
dispose();
});
it('works with string selector (toJS is a no-op)', () => {
const store = createPrimitiveStore();
const effects: string[] = [];
const dispose = reaction(
() => toJS(store.name),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.setName('updated');
expect(effects).toEqual(['initial', 'updated']);
dispose();
});
it('does NOT fire when same primitive is set', () => {
const store = createPrimitiveStore();
const effects: number[] = [];
const dispose = reaction(
() => toJS(store.count),
(value) => effects.push(value),
{fireImmediately: true, equals: comparer.structural},
);
store.setCount(0); // same value
expect(effects).toEqual([0]);
dispose();
});
});
});
import {comparer, type IReactionOptions, reaction, toJS} from 'mobx';
import {useEffect, useState} from 'react';
/**
* Subscribes to a MobX observable (or derived value) and returns its current value.
* Components using this hook re-render when the selected value changes and
* do not need to be wrapped in `observer`.
*
* Pattern: sync observables to React state via `reaction` + `useState`,
* as recommended by the MobX maintainer.
*
* @see https://github.com/mobxjs/mobx/discussions/3737
*
* @param selector - Function that reads one or more observables and returns a value.
* Selectors can safely return primitives, observable arrays, or observable objects.
* The output is converted to a plain JS snapshot via `toJS` and compared
* structurally (`comparer.structural`), so manual spreading is not needed.
*
* **Best practice:** keep selectors narrow — select only the data you render.
* Returning a large observable tree causes `toJS` to deep-clone and
* `comparer.structural` to deep-compare on every tracked change.
* @param options - Optional MobX reaction options (e.g. `equals`, `delay`).
* The default `equals` is `comparer.structural`; pass a custom `equals`
* to override (e.g. `comparer.default` for reference equality).
* @returns The current plain JS value from `selector`.
*/
export function useMobxSelector<T>(
selector: () => T,
options?: IReactionOptions<T, false>,
): T {
const [value, setValue] = useState(() => toJS(selector()));
// biome-ignore lint/correctness/useExhaustiveDependencies: selector/options close over stable store refs; re-subscribing on every render would be wrong
useEffect(() => {
const dispose = reaction(
() => toJS(selector()),
(plainValue) => setValue(plainValue as T),
{
fireImmediately: true,
equals: comparer.structural,
...options,
},
);
return dispose;
}, []);
return value;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment