Transactional state
TxRef is the atom of STM — a
transactional reference, plus the Effect.tx / Effect.txRetry mechanics
that make groups of updates atomic.
Concurrent code that shares mutable state is hard to get right. The moment two
fibers read-modify-write the same data, you risk lost updates, partial writes,
and inconsistent reads. Effect’s answer is Software Transactional Memory
(STM): you mark a block of state changes as a transaction with Effect.tx,
and Effect guarantees that the whole block commits atomically — all of it,
or none of it — without you ever writing a lock by hand.
import { Effect, TxRef } from "effect"
// Move money between two accounts. The read, the check, and both writes// must all happen as a single atomic step — no other fiber can observe a// state where the money has left one account but not arrived in the other.const transfer = ( from: TxRef.TxRef<number>, to: TxRef.TxRef<number>, amount: number) => // Effect.tx is the transaction boundary: everything inside commits together Effect.tx( Effect.gen(function*() { const balance = yield* TxRef.get(from) if (balance < amount) { // Failing aborts the transaction — none of the writes below are kept return yield* Effect.fail("insufficient funds" as const) } yield* TxRef.update(from, (n) => n - amount) yield* TxRef.update(to, (n) => n + amount) }) )Without a transaction, a read-modify-write across several values is a sequence
of independent steps, and another fiber can interleave between any two of them.
The transfer above, written with a plain Ref, can lose money: fiber A reads
from, fiber B reads from, both subtract, and one write clobbers the other —
a classic lost update. Worse, a third fiber reading both accounts mid-transfer
can see money that has left from but not yet arrived in to.
STM removes the interleaving entirely. Inside Effect.tx, the body either
observes a single consistent snapshot and commits all its writes at once, or it
sees a conflict and runs again — there is never a partially-applied result.
Effect’s STM is optimistic. A transaction body runs against a private journal of reads and writes rather than touching the shared values directly. At commit time Effect checks whether any value the transaction read was changed by another fiber in the meantime:
There are no locks to acquire and no deadlocks to design around: contention is resolved by re-running the loser, not by blocking the winner.
Effect.tx defines the boundary. The outermost Effect.tx call is what
creates the journal and decides when to commit or roll back; everything that
runs inside it — including nested Effect.tx calls — joins that same
journal instead of starting its own. Nested transactions do not commit
independently, so you can wrap reusable helpers in Effect.tx and still compose
them into a larger atomic unit safely.
import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() { const ref1 = yield* TxRef.make(0) const ref2 = yield* TxRef.make(0)
// The outer tx is the boundary; the inner tx joins the same journal, // so both writes commit together (or not at all). yield* Effect.tx( Effect.gen(function*() { yield* TxRef.set(ref1, 10) yield* Effect.tx(TxRef.set(ref2, 20)) // composes, does not commit alone }) )
console.log(yield* TxRef.get(ref1)) // => 10 console.log(yield* TxRef.get(ref2)) // => 20})A single bare Tx* operation that is not wrapped in Effect.tx still runs
atomically — it executes in its own implicit one-operation transaction. You only
need an explicit Effect.tx when you want several operations to commit as a
unit.
import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() { const ref = yield* TxRef.make(0) // No Effect.tx needed: this single update is its own transaction. yield* TxRef.update(ref, (n) => n + 1)})A transaction can also wait deliberately. Effect.txRetry aborts the current
transaction and suspends it until one of the transactional values it read
changes — then the body re-runs. This turns “there isn’t enough data yet” into a
clean, declarative blocking primitive instead of a polling loop, and it parks
the fiber cheaply (it is only rescheduled when a relevant value actually moves).
import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() { const ref = yield* TxRef.make(0)
// A background fiber slowly raises the value. yield* Effect.forkChild( Effect.forever( Effect.tx(TxRef.update(ref, (n) => n + 1)).pipe(Effect.delay("100 millis")) ) )
// Block until the value reaches 10. Each time `ref` changes, the body // re-runs; while it is below 10 we suspend instead of busy-waiting. yield* Effect.tx( Effect.gen(function*() { const value = yield* TxRef.get(ref) if (value < 10) { return yield* Effect.txRetry } }) )})Effect.txRetry is the building block under every blocking Tx* operation:
TxQueue.take retries while the queue is empty, TxSemaphore.acquire retries
while no permit is free, and TxDeferred.await retries until the deferred is
set. Because they are all built on the same primitive, they compose: a single
Effect.tx can take from a queue and acquire a permit and update a TxRef,
and the whole thing blocks until all of its conditions can be satisfied at once.
Transactional state
TxRef is the atom of STM — a
transactional reference, plus the Effect.tx / Effect.txRetry mechanics
that make groups of updates atomic.
Transactional data structures
TxHashMap, TxHashSet, TxChunk, and the transactional
queues/pubsub — collections
(TxQueue, TxPubSub, TxPriorityQueue, TxSubscriptionRef) whose every
operation composes inside a transaction.
Coordination
TxSemaphore, TxReentrantLock, and
TxDeferred — permits, read/write locks, and
one-shot signals that commit atomically with your state.
Use the Tx* modules whenever more than one fiber touches a piece of state
and a single read-modify-write would race. The classic cases are transfers
between accounts, inventory and reservation systems, work queues, connection
pools, and any “wait until a condition holds” coordination.
For state owned by a single fiber, or where atomicity across multiple values is
not a concern, the simpler Ref and SynchronizedRef
primitives are a better fit — they have less overhead and no retry semantics.
STM earns its keep precisely when you need several values to move together, or
when you need a fiber to block until a condition becomes true.
The Tx* modules supply the data; these three members of Effect supply the
machinery. (The per-module operations are documented on the pages linked above.)
Defines a transaction boundary. The body’s changes to transactional values are
all-or-nothing: it commits on success, rolls back on failure, and re-runs on a
conflict. Nested Effect.tx calls reuse the outermost journal rather than
opening a new boundary.
import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() { const a = yield* TxRef.make(1) const b = yield* TxRef.make(1) // Both updates commit together. yield* Effect.tx( Effect.gen(function*() { yield* TxRef.update(a, (n) => n + 1) yield* TxRef.update(b, (n) => n * 10) }) ) console.log(yield* TxRef.get(a), yield* TxRef.get(b)) // => 2 10})A failure inside the body rolls back every write made in that transaction:
import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() { const ref = yield* TxRef.make(0) yield* Effect.tx( Effect.gen(function*() { yield* TxRef.set(ref, 99) return yield* Effect.fail("abort" as const) // rolls back the set above }) ).pipe(Effect.catch(() => Effect.void)) console.log(yield* TxRef.get(ref)) // => 0})Aborts the current transaction and suspends the fiber until one of the
transactional values read in the body changes, then re-runs the body. It is the
primitive behind every blocking Tx* operation. Its type is
Effect<never, never, Effect.Transaction>, so it can only be used inside an
Effect.tx boundary.
import { Effect, TxRef } from "effect"
// Wait until `gate` is opened (set to true) before proceeding.const waitForOpen = (gate: TxRef.TxRef<boolean>) => Effect.tx( Effect.gen(function*() { const open = yield* TxRef.get(gate) if (!open) return yield* Effect.txRetry // park until `gate` changes }) )// => the fiber suspends, then resumes once another transaction sets `gate` trueThe Context.Service that holds the active transaction’s state — the journal of
pending reads/writes and the retry flag. You rarely access it directly; it is
the service requirement that Effect.tx provides and that Effect.txRetry
depends on. Reading it confirms whether code is currently running inside a
transaction.
import { Effect } from "effect"
const program = Effect.tx( Effect.gen(function*() { const state = yield* Effect.Transaction console.log(state.retry) // => false }))