39 - Ref: Mutable State, the Effect Way
A Ref<A> is a mutable cell whose reads and writes are Effects. Updates are atomic across fibers, the value is invisible to outside code, and every operation composes like any other Effect, retries, interruption, and tests all work without special treatment.
What you’ll work through
index.test.ts covers Ref.make, Ref.get/Ref.set, the pure-update combinator Ref.update, and the read-and-update primitive Ref.modify, then finishes with a stateful service that hides the Ref behind a closure.
By the end you should be able to share state between concurrent fibers without losing updates, and know when a plain const would have been enough.
The Atomic Update Model
Ref<A> is a box holding A.
Ref.get ──▶ Effect<A> read the current value Ref.set ──▶ Effect<void> replace the value Ref.update ──▶ Effect<void> apply (a) => a Ref.modify ──▶ Effect<B> apply (a) => [b, a], return b
Atomicity: `update` / `modify` read-and-write in one step. Two concurrentfibers calling `Ref.update(ref, n => n + 1)` will always agree on thefinal value.A plain let is invisible to the runtime: there is no atomic update, no interruption hook, no way to reset it for a test. Ref is.
Ref.make
Creates a new Ref holding the given value. The constructor is itself an Effect, so the Ref is produced inside Effect.gen, never at module scope.
import { Effect, Ref } from "effect"
// ┌─── Effect<Ref<number>>// ▼const makeCounter = Ref.make(0)When to Use
Whenever you need state that changes during program execution and is observed by Effect code, counters, accumulators, caches, in-memory queues.
Ref.get and Ref.set
// ┌─── Effect<number>// ▼const program = Effect.gen(function* () { const ref = yield* Ref.make(0) yield* Ref.set(ref, 42) return yield* Ref.get(ref)})get snapshots the current value. set replaces it.
Neither operation reads the next value back, use Ref.modify when you need both.
Ref.update
Applies a function to the current value. The function must be pure: it runs inside the atomic compare-and-swap loop, possibly multiple times under contention.
// ┌─── Effect<void>// ▼const inc = (ref: Ref.Ref<number>) => Ref.update(ref, (n) => n + 1)Details
For effectful updates ((A) => Effect<A>) reach for SynchronizedRef in step 40.
Ref.modify
The primitive atomic read-and-update. Returns a value computed from the old state while storing the new state.
// ┌─── Effect<number> (the old value)// ▼const program = Effect.gen(function* () { const ref = yield* Ref.make(100) return yield* Ref.modify(ref, (n) => [n, n + 1]) // ▲ ▲ // │ └── new state // └────── returned value})When to Use
Whenever a single fiber needs to read and decide in one indivisible step, dequeuing, issuing a sequence number, swapping a value while returning the old one.
Sharing State Across Fibers
// ┌─── Effect<number>// ▼const program = Effect.gen(function* () { const counter = yield* Ref.make(0) const increment = Ref.update(counter, (n) => n + 1) yield* Effect.all(Array.from({ length: 100 }, () => increment), { concurrency: "unbounded", }) return yield* Ref.get(counter)})// 100, no lost updatesThe same program written with a plain let and Effect.sync is a race waiting to happen.
A Stateful Service
// ┌─── Effect<{ readonly increment: Effect<void>; readonly get: Effect<number> }>// ▼const makeCounterService = Effect.gen(function* () { const ref = yield* Ref.make(0) return { increment: Ref.update(ref, (n) => n + 1), get: Ref.get(ref), } as const})The Ref is captured in the closure. Callers see only the operations; the state is encapsulated.
A concurrent stack from Ref.modify
Ref.modify atomically reads the current value and writes the next one in the same indivisible step. That’s the building block for any concurrent collection — a counter, a queue, a stack, a set.
The trick is keeping the function pure and returning both the new state and the value the caller wants.
import { Effect, Ref } from "effect"
const program = Effect.gen(function* () { const stack = yield* Ref.make<ReadonlyArray<number>>([])
const push = (n: number) => Ref.update(stack, (xs) => [n, ...xs])
// ┌─── Effect<number | undefined> // ▼ const pop = Ref.modify(stack, (xs) => xs.length === 0 ? [undefined, xs] // [returned value, new state] : [xs[0], xs.slice(1)], )
yield* Effect.all([push(1), push(2), push(3)], { concurrency: "unbounded" }) return [yield* pop, yield* pop, yield* pop]})Even under concurrent push/pop, no two fibers ever see a torn state — modify is the atomic primitive that makes this safe without locks.
Your turn complete `pop` with `Ref.modify` so that read-and-write happen atomically; assert that under concurrent pushes the popped values are exactly the pushed ones (in some order).
Common Pitfalls
1, Using Ref.make at module scope. Ref.make(0) is Effect<Ref<number>>, not Ref<number>. You must yield* it inside an Effect.
2, Mutating inside the update function. The update function must be pure. Returning the same array after a .push breaks atomic semantics and can lose updates under contention.
// WrongRef.update(ref, (arr) => { arr.push(item); return arr })// RightRef.update(ref, (arr) => [...arr, item])3, Using a Ref where the value never changes. If the value is computed once and never reassigned, use a plain const.
4, Reaching for Ref when the update needs to run an Effect. That is what SynchronizedRef (step 40) is for.
survives across `yield*` boundaries. - [ ] Use `Ref.update` with a pure function and prove that 100 concurrent increments produce exactly 100 — no lost updates. - [ ] Use `Ref.modify` to read-and-update in one atomic step (dequeue, issue a sequence number, swap-and-return). - [ ] Build the stateful counter service and confirm callers only see the operations, not the underlying `Ref`.
See Also
- Previous,
38-semaphore: bounding the fibers that contend on aRef. - Next,
40-synchronized-ref: when the update itself is an Effect. - Related,
36-fibers:Refis the primary state-sharing primitive between fibers. - Related,
35-concurrency: contention patterns forEffect.all.
Run the tests: pnpm test 39-ref
The full test suite
The index.test.ts for this lesson, verbatim from steps/39-ref/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Ref } from "effect"
// =============================================================================// 35 - Ref: Mutable State, the Effect Way// =============================================================================//// Ref.make create a Ref<A> — Effect<Ref<A>>// Ref.get read the current value — Effect<A>// Ref.set replace the value — Effect<void>// Ref.update apply (a) => a — Effect<void>// Ref.modify apply (a) => [b, a] — Effect<B>
describe("35 - Ref", () => { // @beat // id: make-and-get // shape: one-todo // title: Create a Ref and read it back // teaches: Ref.make is itself an Effect; yield* it to obtain a Ref, then Ref.get to read. // hint: yield* Ref.make(0), then yield* Ref.get(ref) describe("Ref.make and Ref.get", () => { it("captures an initial value that can be read back", async () => { // ┌─── Effect<number> // ▼ const program = /* @stub */ Effect.gen(function* () { const ref = yield* Ref.make(0) return yield* Ref.get(ref) }) /* @end */
expect(await Effect.runPromise(program)).toBe(0) }) }) // @end-beat
// @beat // id: set // shape: one-todo // title: Replace the value with Ref.set // teaches: Ref.set replaces the stored value; reads after a set observe the new value. // hint: yield* Ref.set(ref, 42) then yield* Ref.get(ref) describe("Ref.set", () => { it("replaces the current value", async () => { const program = /* @stub */ Effect.gen(function* () { const ref = yield* Ref.make(0) yield* Ref.set(ref, 42) return yield* Ref.get(ref) }) /* @end */
expect(await Effect.runPromise(program)).toBe(42) }) }) // @end-beat
// @beat // id: update // shape: multi-todo // title: Update with a pure function // teaches: Ref.update applies (a) => a atomically; the function must be pure because it may re-run under contention. // hint: Ref.update(ref, (n) => n + 5) describe("Ref.update", () => { it("applies a pure function to the current value", async () => { const program = /* @stub */ Effect.gen(function* () { const ref = yield* Ref.make(10) yield* Ref.update(ref, (n) => n + 5) return yield* Ref.get(ref) }) /* @end */
expect(await Effect.runPromise(program)).toBe(15) })
it("composes sequential updates", async () => { const program = /* @stub */ Effect.gen(function* () { const counter = yield* Ref.make(0) yield* Ref.update(counter, (n) => n + 1) yield* Ref.update(counter, (n) => n + 1) yield* Ref.update(counter, (n) => n - 1) return yield* Ref.get(counter) }) /* @end */
expect(await Effect.runPromise(program)).toBe(1) }) }) // @end-beat
// @beat // id: modify // shape: one-todo // title: Read-and-update atomically with Ref.modify // teaches: Ref.modify takes (a) => [b, a] and returns b — read and write happen in one indivisible step. // hint: Ref.modify(ref, (n) => [n, n + 1]) — first element is returned, second is the new state describe("Ref.modify", () => { it("returns the old value while storing the new one — atomically", async () => { // ┌─── Effect<{ old: number; current: number }> // ▼ const program = /* @stub */ Effect.gen(function* () { const ref = yield* Ref.make(100) const old = yield* Ref.modify(ref, (n) => [n, n + 1]) const current = yield* Ref.get(ref) return { old, current } }) /* @end */
const result = await Effect.runPromise(program) expect(result.old).toBe(100) expect(result.current).toBe(101) }) }) // @end-beat
// @beat // id: stateful-service // shape: one-todo // title: Hide a Ref inside a service // teaches: Capture the Ref in a closure and expose only the operations callers should see. // hint: return an object literal exposing { increment: Ref.update(...), get: Ref.get(...) } describe("Concurrent updates", () => { it("loses no updates under unbounded concurrency", async () => { const program = Effect.gen(function* () { const counter = yield* Ref.make(0) const increment = Ref.update(counter, (n) => n + 1) yield* Effect.all(Array.from({ length: 100 }, () => increment), { concurrency: "unbounded", }) return yield* Ref.get(counter) })
expect(await Effect.runPromise(program)).toBe(100) }) })
describe("Capturing state in a service", () => { it("exposes operations while hiding the Ref itself", async () => { const makeCounter = /* @stub */ Effect.gen(function* () { const ref = yield* Ref.make(0) return { increment: Ref.update(ref, (n) => n + 1), get: Ref.get(ref), } as const }) /* @end */
const program = Effect.gen(function* () { const counter = yield* makeCounter yield* counter.increment yield* counter.increment return yield* counter.get })
expect(await Effect.runPromise(program)).toBe(2) }) }) // @end-beat
// @beat // id: concurrent-stack // shape: worked-then-your-turn // title: Build a concurrent stack using Ref // teaches: Ref.modify atomically reads the old value and stores the new one — the same pattern that makes a concurrent counter works for a concurrent stack. // hint: Ref.modify(ref, (stack) => [stack[0], stack.slice(1)]) for pop — return [poppedValue, newStack]
describe("Worked: concurrent counter", () => { it("uses Ref.update for an atomic increment", async () => { const program = Effect.gen(function* () { const counter = yield* Ref.make(0) yield* Effect.all(Array.from({ length: 10 }, () => Ref.update(counter, (n) => n + 1)), { concurrency: "unbounded", }) return yield* Ref.get(counter) })
expect(await Effect.runPromise(program)).toBe(10) }) })
describe("Your turn: concurrent stack", () => { it("push and pop from a Ref-backed stack atomically", async () => { // ┌─── Effect<{ pushed: number[]; popped: number[] }> // ▼ const program = /* @stub */ Effect.gen(function* () { const stack = yield* Ref.make<number[]>([])
// push: prepend to the array const push = (n: number) => Ref.update(stack, (s) => [n, ...s]) // pop: return the head, store the tail — use Ref.modify const pop = Ref.modify(stack, (s) => [s[0] ?? -1, s.slice(1)])
yield* push(1) yield* push(2) yield* push(3)
const a = yield* pop const b = yield* pop
return { remaining: yield* Ref.get(stack), popped: [a, b] } }) /* @end */
const result = await Effect.runPromise(program) expect(result.popped).toEqual([3, 2]) // LIFO expect(result.remaining).toEqual([1]) }) }) // @end-beat})