Transactions

Transactions batch multiple observable mutations together. Reactions only run after the outermost transaction completes, ensuring observers never see partially-updated state.

Signature

function runInTransaction<T>(fn: () => T): T
function transaction<T extends (...args: any[]) => any>(
  fn: T,
  options?: TransactionOptions,
): T

runInTransaction executes a function immediately inside a transaction and returns its result.

transaction is a higher-order function — it wraps a function and returns a new function. Each call to the returned function runs inside a transaction.

Basic usage

import { autorun, observableBox, runInTransaction } from "@fobx/core"

const first = observableBox("Alice")
const last = observableBox("Smith")

let runs = 0
const stop = autorun(() => {
  runs++
  console.log(`${first.get()} ${last.get()}`)
})
// runs = 1, prints: Alice Smith

runInTransaction(() => {
  first.set("Bob") // deferred
  last.set("Jones") // deferred
})
// runs = 2, prints: Bob Jones
// Never sees "Bob Smith"

stop()

Return value

runInTransaction returns whatever the body returns:

const result = runInTransaction(() => {
  x.set(1)
  y.set(2)
  return x.get() + y.get()
})
// result = 3

Nesting

Transactions nest — only the outermost one triggers the reaction flush:

runInTransaction(() => { // depth 1
  a.set(1)
  runInTransaction(() => { // depth 2
    b.set(2)
  }) // depth 2 ends — still batched
  c.set(3)
}) // depth 1 ends — flush

transaction() — wrapping for reuse

transaction wraps a function so that every invocation runs inside a transaction:

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

const a = observableBox(0)
const b = observableBox(0)

const reset = transaction(() => {
  a.set(0)
  b.set(0)
})

// Each call is automatically batched:
reset()

Actions as transactions

Functions annotated as "transaction" (or "transaction.bound") in observable() / makeObservable() are automatically wrapped in a transaction:

import { makeObservable } from "@fobx/core"

class Store {
  x = 0
  y = 0

  move(dx: number, dy: number) {
    this.x += dx // batched
    this.y += dy // batched
  }

  constructor() {
    makeObservable(this, {
      annotations: {
        x: "observable",
        y: "observable",
        move: "transaction",
      },
    })
  }
}

isTransaction()

Check whether a function was wrapped by transaction():

import { isTransaction, transaction } from "@fobx/core"

const reset = transaction(() => {})

console.log(isTransaction(reset)) // true
console.log(isTransaction(() => {})) // false

FobX does not currently expose a public “am I inside a transaction right now?” predicate.

Error handling

If the transaction body throws, the error propagates to the caller. Pending reactions from mutations that occurred before the error are still flushed:

try {
  runInTransaction(() => {
    x.set(1) // mutation registers
    throw new Error("oops")
  })
} catch (e) {
  // x.set(1) was committed; reactions already ran
}

When to use

  • Wrapping multi-property updates to prevent intermediate renders.
  • Event handlers that modify multiple observables.
  • Any code path where you want reactions to see a consistent snapshot.

Prefer "transaction" annotations on class methods over explicit runInTransaction() calls — they compose better and are less error-prone.