reaction

reaction is a two-phase reaction: a tracked expression that produces a value, and an effect that runs (untracked) whenever that value changes.

Signature

function reaction<T>(
  expression: (dispose: Dispose) => T,
  effect: (
    value: T,
    previousValue: T | typeof UNDEFINED,
    dispose: Dispose,
  ) => void,
  options?: ReactionOptions<T>,
): Dispose

interface ReactionOptions<T> {
  name?: string
  fireImmediately?: boolean
  comparer?: EqualityComparison
}

Parameters

Parameter Type Description
expression (dispose) => T Tracked function — its return value is compared across runs
effect (value, prev, dispose) => void Side-effect — runs untracked when the expression value changes
options.name string Debug name
options.fireImmediately boolean Run the effect once immediately with the initial value (default false)
options.comparer EqualityComparison Custom equality check for the expression result

When fireImmediately is true, the first callback receives UNDEFINED as previousValue.

Returns a Dispose function.

Basic usage

import { observableBox, reaction } from "@fobx/core"

const temperature = observableBox(20)

const stop = reaction(
  () => temperature.get(),
  (current, previous) => {
    console.log(`Temperature changed from ${previous} to ${current}`)
  },
)

temperature.set(25)
// prints: Temperature changed from 20 to 25

temperature.set(25)
// nothing — same value

stop()

Fire immediately

const stop = reaction(
  () => temperature.get(),
  (current) => {
    console.log("temperature:", current)
  },
  { fireImmediately: true },
)
// prints: temperature: 20 (immediately)

On that first fireImmediately run, previousValue is the exported UNDEFINED sentinel.

Custom comparer

Use a custom comparer to control when the effect fires. The "structural" comparer requires a one-time configure() call at app startup:

import { configure } from "@fobx/core"
configure({ comparer: { structural: myDeepEqual } })
const coords = observableBox({ x: 0, y: 0 })

const stop = reaction(
  () => coords.get(),
  (value) => console.log("moved to", value),
  { comparer: "structural" },
)

coords.set({ x: 0, y: 0 }) // no effect — structurally equal
coords.set({ x: 1, y: 0 }) // effect fires
stop()

Expression vs effect tracking

Only the expression function is tracked. The effect runs outside the tracking context, so observable reads inside the effect do not create dependencies:

const a = observableBox(1)
const b = observableBox(2)

const stop = reaction(
  () => a.get(), // only a is tracked
  () => {
    console.log(b.get()) // b is read but NOT tracked
  },
)

b.set(99) // does NOT trigger the reaction
a.set(2) // triggers the reaction → prints 99

stop()

Collection change detection

When the expression returns an observable collection, reaction compares by internal change count rather than reference. This means mutations to the collection are detected:

import { observableMap, reaction } from "@fobx/core"

const m = observableMap<string, number>()

const stop = reaction(
  () => m,
  () => console.log("map changed, size:", m.size),
)

m.set("a", 1) // prints: map changed, size: 1
m.set("b", 2) // prints: map changed, size: 2

stop()

Self-disposing

Both the expression and effect receive a dispose callback:

const stop = reaction(
  (dispose) => {
    const v = temperature.get()
    if (v > 100) dispose() // stop if temperature exceeds 100
    return v
  },
  (value) => console.log("temp:", value),
)

When to use

Use case Recommended
React to a specific derived value reaction
Run side-effects on any read autorun
Wait for a boolean condition when