41 - SubscriptionRef: Observable Mutable State
Ref holds mutable state. SubscriptionRef adds a reactive layer on top — every change is broadcast as a Stream<A> to any fiber that subscribes.
What you’ll work through
index.test.ts contains three beats: constructing a SubscriptionRef with make and updating it with set / update, observing the changes stream from a separate fiber, and comparing Ref vs SubscriptionRef for when each is the right choice.
By the end you should reach for SubscriptionRef whenever state needs to broadcast, and stick with Ref when it’s purely shared.
Mental Model
Ref SubscriptionRef───────────────── ──────────────────────────── make(initial) make(initial) get → current value get → current value set(newValue) set(newValue) ─── broadcasts ──► changes stream ↓ every subscriber fiberSubscriptionRef.make and set
The cell-shaped API of SubscriptionRef is identical to Ref — SubscriptionRef.make(initial) produces an Effect that yields the cell, and SubscriptionRef.set(ref, value) replaces the contents. If you only ever call make, set, and get, you cannot tell a SubscriptionRef from a Ref at the call site.
import { Effect, SubscriptionRef } from "effect"
const program = Effect.gen(function* () { const counter = yield* SubscriptionRef.make(0) yield* SubscriptionRef.set(counter, 1) return yield* SubscriptionRef.get(counter) // 1})The difference appears only when you reach for ref.changes — the broadcast stream of every value the cell ever held. Until you do, treat SubscriptionRef exactly like Ref.
Your turn make a `SubscriptionRef
ref.changes — a Stream of every value
ref.changes is a Stream<A> that emits the current value immediately on subscription and then every value produced by a subsequent set (or update, modify). It is the observable side of a SubscriptionRef — subscribers receive every state transition without polling.
import { Effect, SubscriptionRef, Stream } from "effect"
const program = Effect.gen(function* () { const counter = yield* SubscriptionRef.make(0)
// Take the first 3 emissions of counter.changes — including the initial value. const collected = yield* counter.changes.pipe( Stream.take(3), Stream.runCollect, )
// ...somewhere else, drive updates: // yield* SubscriptionRef.set(counter, 1) // yield* SubscriptionRef.set(counter, 2)})The tricky part is timing: you must subscribe before driving new values, or the stream misses them. A common pattern is to fork the subscription, signal a Deferred on first emission, and only then publish further values.
The test demonstrates the pattern end-to-end.
Your turn subscribe to `counter.changes`, take the first three emissions, and confirm the captured chunk contains the initial value and both subsequent `set`s in order.
Ref vs SubscriptionRef
Ref is a cell: callers ask for the current value on demand. There is no notification — if you want to know when the value changed, you poll.
SubscriptionRef is a cell and a broadcast channel: every value produced is offered to every active subscriber’s stream. Multiple fibers can react to state changes without polling, and without a central dispatcher.
| Need | Use |
|---|---|
| One reader, occasional writes | Ref |
| Many readers, must observe every change | SubscriptionRef |
| Many readers, only need latest on demand | Ref |
| Replay-style observation for new subscribers | SubscriptionRef (the initial value is replayed) |
The cost difference is small for in-process state. The clarity gain — no polling loop, no manual change detection — is large enough that SubscriptionRef becomes the default once two fibers care about the same value.
Your turn read the test that exercises both APIs side-by-side and confirm the mutation code is identical. Then notice how the `SubscriptionRef` version eliminates the polling loop the `Ref` version needed.
value behaves like a `Ref`. - [ ] Subscribe to its `changes` stream from a separate fiber and observe every transition. - [ ] Compare with a plain `Ref` for the same workflow — the difference is the broadcast.
See Also
- Previous,
40-synchronized-ref: atomic effectful updates - Next,
46-scope: resource lifecycle - Later,
34-streams: the fullStreamAPI
Run the tests: pnpm test 41-subscription-ref
The full test suite
The index.test.ts for this lesson, verbatim from steps/41-subscription-ref/.
import { describe, expect, it } from "vite-plus/test"import { Effect, Fiber, Latch, Stream, SubscriptionRef } from "effect"
describe("SubscriptionRef", () => { it("combines current state with a stream of later changes", async () => { const program = Effect.scoped(Effect.gen(function*() { const ref = yield* SubscriptionRef.make(0) const subscribed = yield* Latch.make() const fiber = yield* SubscriptionRef.changes(ref).pipe( Stream.tap(() => subscribed.open), Stream.take(3), Stream.runCollect, Effect.forkScoped, ) yield* subscribed.await yield* SubscriptionRef.set(ref, 1) yield* SubscriptionRef.set(ref, 2) return yield* Fiber.join(fiber) })) expect(await Effect.runPromise(program)).toEqual([0, 1, 2]) })})