Transactional state
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 }})Creating and reading a reference
Section titled “Creating and reading a reference”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:
| Operation | Purpose |
| --- | --- |
| 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])Composing transactions
Section titled “Composing transactions”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}`) }) )})Optimism and re-execution
Section titled “Optimism and re-execution”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:
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.