07·03SubscriptionRef

`Ref` holds mutable state. `SubscriptionRef` adds a reactive layer on top — every change is broadcast as a `Stream<A>` to any fiber that subscribes.

41 - SubscriptionRef: Observable Mutable State

Previous · Next

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 fiber

SubscriptionRef.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(0)`, set it to `1`, and read it back. Confirm the value is `1` — the API surface for reading and writing is unchanged from `Ref`.


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 full Stream API

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/.

index.test.ts
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])
})
})
← PreviousSynchronizedRefNext →Queue