Created
February 27, 2026 03:30
-
-
Save codeBelt/8601de78348522ae7429958dc5d26b40 to your computer and use it in GitHub Desktop.
MobX React Hook
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| }); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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