SynchronizedRef
A SynchronizedRef<A> is a Ref whose updates can run
effects while computing the next value — and whose effectful updates are
serialized so they never overlap. Internally it guards updates with a semaphore:
while one effectful update runs, any other update to the same reference waits its
turn, then re-reads the now-current value.
Use it when the next value depends on an effect (a network call, a database
lookup, a Clock read) and those updates must not race with one
another.
The problem it solves
Section titled “The problem it solves”With a plain Ref you could read the value, run an effect, then write the result
back — but that is three separate operations. Between the read and the write,
another fiber can change the value, and your write silently overwrites theirs.
SynchronizedRef.updateEffect closes that gap: it holds the lock for the whole
read → effect → write cycle.
import { Effect, SynchronizedRef } from "effect"
// Pretend this fetches the latest score for a user from a remote service.const fetchDelta = (current: number): Effect.Effect<number> => Effect.succeed(current + 10)
const program = Effect.gen(function*() { const score = yield* SynchronizedRef.make(0)
// The update function returns an Effect. The whole read-run-write cycle is // serialized, so concurrent updates apply one after another instead of // racing on a stale value. const bump = SynchronizedRef.updateEffect(score, (n) => fetchDelta(n))
// Run several effectful updates concurrently — they still compose correctly. yield* Effect.all([bump, bump, bump], { concurrency: "unbounded" })
return yield* SynchronizedRef.get(score) // 30})If score were a plain Ref and we tried to express this as a get, then a
fetch, then a set, the three concurrent updates could each read 0 and write
10, losing two of the three increments. With SynchronizedRef the result is
always 30.
Same API as Ref, plus effectful variants
Section titled “Same API as Ref, plus effectful variants”SynchronizedRef supports every operation a Ref does — get, set, update,
modify, and their andGet / getAnd / Some variants — so you can use it as a
drop-in replacement. The addition is the Effect suffix family, where the update
function returns an effect:
updateEffect— replace the value with the result of an effectful function.updateAndGetEffect/getAndUpdateEffect— the same, returning the new or previous value.modifyEffect— effectfully compute both a return value and the next stored value.updateSomeEffect,modifySomeEffect, … — conditional effectful updates driven by anOption.
import { Effect, SynchronizedRef } from "effect"
const program = Effect.gen(function*() { const ref = yield* SynchronizedRef.make(100)
// modifyEffect returns a tuple [result, newValue]. The result is handed back // to the caller; the newValue is stored. Both are computed atomically under // the lock, even though the computation is effectful. const withdrawn = yield* SynchronizedRef.modifyEffect(ref, (balance) => Effect.gen(function*() { const amount = balance >= 30 ? 30 : 0 yield* Effect.log(`Withdrawing ${amount} from ${balance}`) return [amount, balance - amount] as const }))
const remaining = yield* SynchronizedRef.get(ref) return { withdrawn, remaining } // { withdrawn: 30, remaining: 70 }})A serialized resource pool
Section titled “A serialized resource pool”A common use is coordinating access to a shared, effectfully-managed resource.
Here a service hands out connections from a pool, replenishing it with an effect
when it runs dry — and SynchronizedRef guarantees two fibers never grab the
same slot.
import { Context, Effect, Layer, SynchronizedRef } from "effect"
class ConnectionPool extends Context.Service<ConnectionPool, { // Borrow the next available connection id, opening more if needed. readonly acquire: Effect.Effect<number>}>()("app/ConnectionPool") { static layer = Layer.effect( ConnectionPool, Effect.gen(function*() { // State: the ids currently free to hand out. const free = yield* SynchronizedRef.make<Array<number>>([]) let nextId = 0
const openConnection = Effect.sync(() => nextId++)
const acquire = SynchronizedRef.modifyEffect(free, (available) => available.length > 0 // Fast path: reuse a free connection. No effect needed, but the whole // decision is still serialized so no two fibers take the same id. ? Effect.succeed([available[0], available.slice(1)] as const) // Slow path: open a new connection effectfully while holding the lock. : Effect.map(openConnection, (id) => [id, [] as Array<number>] as const))
return ConnectionPool.of({ acquire }) }) )}
const program = Effect.gen(function*() { const pool = yield* ConnectionPool // Even acquiring concurrently, each fiber receives a distinct id. const ids = yield* Effect.all( [pool.acquire, pool.acquire, pool.acquire], { concurrency: "unbounded" } ) return ids // three distinct connection ids}).pipe(Effect.provide(ConnectionPool.layer))When to prefer a plain Ref
Section titled “When to prefer a plain Ref”If your update is a pure function of the current value, a plain
Ref is already atomic and has no locking overhead —
use it. Reach for SynchronizedRef only when computing the next value requires an
effect that must stay serialized. And if you need the whole group of references
to update transactionally together, use transactions (STM).