Skip to content

Transactional state (TxRef)

A TxRef<A> is the atom of Effect’s STM: a mutable reference whose reads and writes can take part in a transaction. On its own a TxRef behaves much like a Ref. What makes it special is that, inside an Effect.tx block, every TxRef you touch is recorded in a private journal and committed together — so a group of updates becomes a single atomic step that no other fiber can interleave with.

import { Effect, TxRef } from "effect"
// A tiny bank: two transactional accounts.
const program = Effect.gen(function*() {
const checking = yield* TxRef.make(100)
const savings = yield* TxRef.make(0)
// Everything inside Effect.tx commits as one atomic unit. Another fiber
// can never observe a moment where the money has left `checking` but not
// yet arrived in `savings`.
yield* Effect.tx(
Effect.gen(function*() {
const balance = yield* TxRef.get(checking)
if (balance < 30) {
// Aborting the transaction discards every write made above —
// the journal is thrown away and nothing is committed.
return yield* Effect.fail("insufficient funds" as const)
}
yield* TxRef.set(checking, balance - 30)
yield* TxRef.update(savings, (amount) => amount + 30)
})
)
return {
checking: yield* TxRef.get(checking), // 70
savings: yield* TxRef.get(savings) // 30
}
})

TxRef.make returns an Effect that allocates the reference, so you create one with yield* inside Effect.gen. There is also TxRef.makeUnsafe for the rare case where you must construct one outside an Effect (for example, a module-level singleton).

The four core operations all return Effects:

OperationPurpose
TxRef.get(ref)read the current value
TxRef.set(ref, value)replace the value
TxRef.update(ref, f)transform the value with f
TxRef.modify(ref, f)transform the value and return a derived result

modify is the most general: its function returns a tuple [returnValue, newValue], letting you compute a result from the old value while writing a new one in the same step.

import { Effect, TxRef } from "effect"
const nextId = (counter: TxRef.TxRef<number>) =>
// Atomically hand out an id and advance the counter. Returns the id that
// was just allocated; stores the incremented value.
TxRef.modify(counter, (current) => [current, current + 1])

The outermost Effect.tx call defines the boundary. If you call Effect.tx again inside an active transaction, it does not start a new nested transaction — it joins the current one, reusing the same journal. This means you can build small transactional helpers and freely compose them; they merge into whatever transaction is running when they are called.

import { Effect, TxRef } from "effect"
// Reusable transactional steps. Each is safe to run on its own AND composes
// into a larger transaction when called inside one.
const withdraw = (account: TxRef.TxRef<number>, amount: number) =>
Effect.tx(TxRef.update(account, (n) => n - amount))
const deposit = (account: TxRef.TxRef<number>, amount: number) =>
Effect.tx(TxRef.update(account, (n) => n + amount))
const transfer = (
from: TxRef.TxRef<number>,
to: TxRef.TxRef<number>,
amount: number
) =>
// The outer tx is the real boundary; the inner txs join it, so the two
// updates commit together as one atomic transfer.
Effect.tx(
Effect.gen(function*() {
yield* withdraw(from, amount)
yield* deposit(to, amount)
})
)

Waiting for a condition with Effect.txRetry

Section titled “Waiting for a condition with Effect.txRetry”

Sometimes a transaction can’t make progress yet — there isn’t enough balance, the queue is empty, the flag isn’t set. Instead of failing or polling, call Effect.txRetry. This suspends the transaction and re-runs it only when one of the TxRef values it read changes. It is the building block for every “block until ready” primitive in the Tx* family.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const ref = yield* TxRef.make(0)
// A producer fiber bumps the value every 100ms.
yield* Effect.forkChild(
Effect.forever(
Effect.tx(TxRef.update(ref, (n) => n + 1)).pipe(Effect.delay("100 millis"))
)
)
// This transaction parks itself until `ref` reaches 10. Each time `ref`
// changes, Effect wakes the transaction and re-checks the condition —
// no busy-waiting, no manual sleeping.
yield* Effect.tx(
Effect.gen(function*() {
const value = yield* TxRef.get(ref)
if (value < 10) {
return yield* Effect.txRetry
}
yield* Effect.log(`reached ${value}`)
})
)
})

Effect’s STM is optimistic: a transaction runs against its journal and, at commit time, verifies that nothing it read was changed by another fiber. If a conflict is detected, the journal is discarded and the body runs again from the start against fresh values. This is what keeps transactions correct under contention without any explicit locking.

The practical consequence is that a transaction body may execute more than once. Keep the body pure with respect to the outside world:

Everything TxRef exports is below. The two constructors aside, every operation returns an Effect and is built on top of modify — so they all read and write the transaction journal when a transaction is active, and run in their own implicit transaction otherwise.

The transactional reference type. A is the stored value type; the parameter is invariant (in out A) because the reference is both read and written. You rarely construct this directly — use the constructors below — but you will annotate it when writing helpers over a reference.

import { Effect, TxRef } from "effect"
// Annotate a parameter so a helper accepts any number-valued reference.
const increment = (ref: TxRef.TxRef<number>) =>
TxRef.update(ref, (n) => n + 1)
const program = Effect.gen(function*() {
const counter: TxRef.TxRef<number> = yield* TxRef.make(0)
yield* increment(counter)
return yield* TxRef.get(counter)
// => 1
})

Allocates a new TxRef with an initial value, returning an Effect that yields the reference. This is the normal way to create transactional state inside an Effect.gen workflow.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const counter = yield* TxRef.make(0)
const name = yield* TxRef.make("Alice")
return [yield* TxRef.get(counter), yield* TxRef.get(name)]
// => [0, "Alice"]
})

Creates a TxRef synchronously, returning the reference directly rather than an Effect. Use it only outside an Effect context — for example a module-level singleton; prefer make inside Effect code.

import { TxRef } from "effect"
const counter = TxRef.makeUnsafe(0)
const config = TxRef.makeUnsafe({ timeout: 5000, retries: 3 })
console.log(counter.value) // => 0
console.log(config.value) // => { timeout: 5000, retries: 3 }

Reads the current value of a reference, returning an Effect<A>. Reading inside a transaction records the reference in the journal, so a later write by another fiber will be detected as a conflict and trigger a retry.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const counter = yield* TxRef.make(42)
return yield* TxRef.get(counter)
// => 42
})

Replaces the value of a reference, returning Effect<void>. Dual: both TxRef.set(ref, value) and the pipeable TxRef.set(value) forms are available.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const counter = yield* TxRef.make(0)
yield* TxRef.set(counter, 100)
return yield* TxRef.get(counter)
// => 100
})

Transforms the value with a function (current) => next, returning Effect<void>. Use it when you don’t need a result back — just a new value derived from the old one. Dual, so ref.pipe(TxRef.update(f)) also works.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const counter = yield* TxRef.make(10)
yield* TxRef.update(counter, (current) => current * 2)
return yield* TxRef.get(counter)
// => 20
})

The most general operation: the function returns a tuple [returnValue, newValue]. It writes newValue into the reference and yields returnValue from the Effect. get, set, and update are all defined in terms of modify. Dual, so ref.pipe(TxRef.modify(f)) also works.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const counter = yield* TxRef.make(0)
// Return the doubled current value, store current + 1.
const result = yield* TxRef.modify(counter, (current) => [current * 2, current + 1])
return [result, yield* TxRef.get(counter)]
// => [0, 1] (returnValue is 0 * 2; new value is 0 + 1)
})

TxRef gives you atomic state over plain values. When your state is a collection — a map, a set, a queue — reach for the purpose-built transactional data structures, whose every operation already participates in the same journal. For non-STM fiber coordination (latches, semaphores, queues), see the concurrency section.