Skip to content

Ref

A Ref<A> is a fiber-safe mutable cell holding a single value of type A. It is the workhorse of Effect state management: a controlled, atomic alternative to a plain mutable variable. Reads, writes, and transformations are all effects, so they sequence with the rest of your program and stay correct when several fibers touch the same Ref concurrently.

Reach for a Ref whenever the next value is a pure function of the current one — a counter, an accumulator, a small piece of in-memory configuration.

import { Effect, Ref } from "effect"
const program = Effect.gen(function*() {
// `make` returns an effect, because allocating shared state is itself an
// effect — it must happen at a well-defined point in the program.
const counter = yield* Ref.make(0)
// `update` reads, transforms, and writes back in one atomic step. Prefer it
// over a separate `get` + `set` whenever the new value depends on the old.
yield* Ref.update(counter, (n) => n + 1)
// `get` reads the current value without changing it.
return yield* Ref.get(counter) // 1
})

A handful of operations cover almost everything:

  • Ref.get / Ref.set — read the value, or replace it with a known value.
  • Ref.update — replace the value with f(current), atomically. Use this whenever the new value depends on the old one.
  • Ref.modify — atomically compute two things from the current value: a return value and the next stored value.

modify is the most general operation. It takes a function returning a [result, newValue] tuple, stores newValue, and hands you back result. This lets you read-and-update in a single atomic step — useful for things like handing out unique IDs:

import { Effect, Ref } from "effect"
const program = Effect.gen(function*() {
const nextId = yield* Ref.make(1)
// Atomically take the current id and advance the counter. No two fibers can
// ever observe the same id, even calling this concurrently.
const allocate = Ref.modify(nextId, (id) => [id, id + 1] as const)
const a = yield* allocate // 1
const b = yield* allocate // 2
return [a, b]
})

Each operation also comes in andGet / getAnd variants when you want the new or previous value back: updateAndGet, getAndUpdate, getAndSet, setAndGet. The Some variants (updateSome, modifySome, getAndUpdateSome) take a function returning an Option and leave the value untouched when it returns Option.none(), which is handy for conditional state transitions.

A Ref is rarely passed around by hand. Instead, wrap it in a service so any subprogram can reach the same shared state. This is the idiomatic v4 way to expose mutable state, and it keeps the operations you allow on the state in one place.

import { Context, Effect, Layer, Ref } from "effect"
// A small counter service. The Ref stays private inside the layer; consumers
// only see the operations we choose to expose.
class Counter extends Context.Service<Counter, {
readonly increment: Effect.Effect<void>
readonly decrement: Effect.Effect<void>
readonly current: Effect.Effect<number>
}>()("app/Counter") {
// The layer allocates the Ref once, when the service is constructed.
static layer = Layer.effect(
Counter,
Effect.gen(function*() {
const ref = yield* Ref.make(0)
return Counter.of({
increment: Ref.update(ref, (n) => n + 1),
decrement: Ref.update(ref, (n) => n - 1),
current: Ref.get(ref)
})
})
)
}
const program = Effect.gen(function*() {
const counter = yield* Counter
yield* counter.increment
yield* counter.increment
yield* counter.decrement
return yield* counter.current // 1
})
// Every fiber that runs with this layer shares the same underlying Ref.
const runnable = program.pipe(Effect.provide(Counter.layer))

Because the service exposes only increment, decrement, and current, callers cannot set the counter to an arbitrary value — the Ref is an implementation detail.

A Ref shines when several fibers update the same state at once. Each individual update is atomic, so concurrent increments cannot clobber one another.

import { Effect, Fiber, Ref } from "effect"
const program = Effect.gen(function*() {
const ref = yield* Ref.make(0)
// Fork two fibers that each increment the shared counter many times.
const work = Effect.forEach(
Array.from({ length: 100 }, (_, i) => i),
() => Ref.update(ref, (n) => n + 1),
{ discard: true }
)
const fiber1 = yield* Effect.forkChild(work)
const fiber2 = yield* Effect.forkChild(work)
// Wait for both to finish before reading the final total.
yield* Fiber.join(fiber1)
yield* Fiber.join(fiber2)
return yield* Ref.get(ref) // 200 — no updates were lost
})

The result is always 200: because update is atomic, the two fibers’ increments are correctly interleaved rather than racing on a read-modify-write.

A plain Ref cannot run an effect to compute its next value while staying serialized — its update functions must be pure. If your update needs to perform an effect (call an API, read the Clock) and must not interleave with other updates, use SynchronizedRef. If other parts of the program need to observe every change as a stream, use SubscriptionRef.