A review queue’s toolbar can receive its selection model as a parameter. A keyboard command built in another module may need to request that same model. Effect Context supplies this dependency without making the model a module-global singleton. The service declares what the consumer needs; a Layer supplies one implementation in the lifetime where those consumers run.
Start with renderer-independent state. The choice here is not whether state is “global.” It is which capability crosses a construction boundary and which owner provides it. Two independently provided implementations of the same service can legitimately represent two workspaces or two tests. Choose the smallest capability the consumer needs:
Choose the public capability before its facade
| Consumer needs | Contract | Example |
|---|---|---|
| Observe values | Fx.Service | Read a transport’s incoming events |
| Submit values | Sink.Service | Send audit records to an owner |
| Publish and observe events | Subject.Service | Shared notification bus without current state |
| Submit one type and observe another | Push.Service | Command input paired with a reply stream |
| Read an external snapshot, updates, and version | Versioned.Service | External store adapter |
| Read, observe, and replace state | RefSubject.Service | Internal feature model trusted by its consumers |
| Read state and invoke constrained commands | Custom Context service | Selection with a uniqueness invariant |
RefSubject.Service is convenient, but importing it grants arbitrary writes. If every caller must
preserve uniqueness, expose the selected IDs and a select operation instead of the internal ref.
import { Context, Effect, Layer } from "effect"
import { RefSubject } from "@typed/fx"
export class Selection extends Context.Service<Selection, {
readonly selected: RefSubject.Computed<ReadonlyArray<string>>
readonly select: (id: string) => Effect.Effect<ReadonlyArray<string>>
readonly clear: Effect.Effect<ReadonlyArray<string>>
}>()("docs/Selection") {}
export const SelectionLive = Layer.effect(Selection, Effect.gen(function* () {
const state = yield* RefSubject.make<ReadonlyArray<string>>([])
return {
selected: state,
select: (id: string) => RefSubject.update(state, (ids) =>
ids.includes(id) ? ids : [...ids, id],
),
clear: RefSubject.set(state, []),
}
}))
The Layer owns the ref it constructs. Callers cannot accidentally bypass select with a generic
set, because the service does not expose that operation. This is a TypeScript capability boundary,
not a security boundary against arbitrary code executing in the same process.
Keep the dependency in the consumer’s type
A library can define a service-backed query without choosing where the model is built.
computedFromService returns a Computed that retrieves the actual view when read or observed.
Import the same Selection service in the consumer module:
import { Effect } from "effect"
import { RefSubject } from "@typed/fx"
import { Selection } from "./Selection.js"
export const selectedCount = RefSubject.computedFromService(
Effect.map(Selection, ({ selected }) => RefSubject.map(selected, (ids) => ids.length)),
)
selectedCount requires Selection. Passing the Computed into a component keeps that dependency
visible until the component runs; passing its current number intentionally takes a snapshot.
filteredFromService does the same for a Filtered view, retaining its meaningful absence behavior.
Both retain the source’s errors and services in addition to the service being retrieved.
Provide the Layer around the routes, commands, and views that should share one selection. Installing new Layers independently around each consumer can create independent state. If a command succeeds but a view does not change, compare their provider boundaries and actual ref identities before adding subscriptions that copy state between them.
Use a RefSubject facade when full writes are the contract
Some subsystems intentionally share a whole mutable state value. RefSubject.Service<Self, A, E>()
creates a Context-backed facade with current reads, Fx observation, and serialized writes.
import { Effect } from "effect"
import { RefSubject } from "@typed/fx"
class QueueSettings extends RefSubject.Service<QueueSettings, {
readonly density: "compact" | "comfortable"
}>()("docs/QueueSettings") {}
const QueueSettingsLive = QueueSettings.make({ density: "comfortable" })
const compact = RefSubject.update(QueueSettings, () => ({ density: "compact" as const }))
const inspect = Effect.gen(function* () {
yield* compact
return yield* QueueSettings
}).pipe(Effect.provide(QueueSettingsLive), Effect.scoped)
The class is a dependency key and facade, not a singleton allocation. make(initial) creates a
Layer that builds the ref; layer(effectThatBuildsARef) accepts a custom construction operation.
Layer acquisition errors differ from later ref read errors. A successful Layer build does not
prove a lazy initializer will succeed when the state is first read.
A test can provide a new QueueSettings Layer with a different initial value. It does not need to patch a global or render the settings control. Use one provision around the whole test journey so commands and reads observe the same state.
Read the value or retrieve the ref deliberately
For a RefSubject service, yield* QueueSettings reads the current state; it does not return the
ref. yield* QueueSettings.service retrieves the underlying ref. Fx.observe(QueueSettings, ... )
observes changes, and RefSubject.update(QueueSettings, ...) performs serialized updates.
These operations retain the QueueSettings requirement until its Layer is provided.
An Effect passed to make remains lazy even after Layer acquisition. This example makes that
boundary visible without a network request:
import { Effect, Ref } from "effect"
import { RefSubject } from "@typed/fx"
class Count extends RefSubject.Service<Count, number>()("docs/LazyCount") {}
const program = Effect.gen(function* () {
const loads = yield* Ref.make(0)
const initial = Effect.gen(function* () {
yield* Ref.update(loads, (count) => count + 1)
return 10
})
const CountLive = Count.make(initial)
return yield* Effect.gen(function* () {
const before = yield* Ref.get(loads)
const first = yield* Count
yield* RefSubject.update(Count, (count) => count + 1)
return { before, first, current: yield* Count, loads: yield* Ref.get(loads) }
}).pipe(Effect.provide(CountLive))
}).pipe(Effect.scoped)
const result = await Effect.runPromise(program)
// { before: 0, first: 10, current: 11, loads: 1 }
make also accepts an Fx source and equality/lifetime options, following
RefSubject source policies. Use
Count.layer(effectThatBuildsARef) for an existing construction procedure, including its own
acquisition failures. Expose a ref as RefSubject.Computed<A> when consumers only need reads;
an identity map adds no useful computation.
Pair different input and output contracts with Push.Service
A Subject publishes and observes the same event type. A Push pairs a Sink input with an Fx output, which can have different types and errors. It does not connect those sides automatically: the supplied implementation must define what an input does and where outputs originate.
import { Effect, Fiber } from "effect"
import { Fx, Push, Sink, Subject } from "@typed/fx"
class Lengths extends Push.Service<Lengths, string, never, number>()("docs/Lengths") {}
const program = Effect.gen(function* () {
const replies = yield* Subject.make<number>(0)
const input = Sink.make<string>(
(cause) => replies.onFailure(cause),
(text) => replies.onSuccess(text.length),
)
const LengthsLive = Lengths.make(input, replies)
return yield* Effect.gen(function* () {
const received = yield* Fx.collectAllFork(Fx.take(Lengths, 2))
yield* Effect.sleep(0)
yield* Lengths.onSuccess("hello")
yield* Lengths.onSuccess("typed")
return yield* Fiber.join(received)
}).pipe(Effect.provide(LengthsLive))
}).pipe(Effect.scoped)
const result = await Effect.runPromise(program)
// [5, 5]
The generic order is Self, Input, InputError, Output, OutputError. Lengths.make(sink, fx)
builds the Layer and captures both implementations’ requirements. onSuccess and onFailure
use the input side; Fx combinators observe the output side. Lengths.service retrieves the
paired value. A test can supply another Sink/Fx pair without changing either consumer.
The example’s enclosing Scope owns the reply Subject; the service facade adds no queue,
request correlation, replay policy, or automatic sharing.
Keep the smallest contract that lets the consumer do its work. Do not add Context merely to avoid passing one local ref to a directly constructed child. Services are useful for independent construction, replaceable infrastructure, and shared ownership. Their lifetime still comes from the providing Layer and Scope, as described by Effect’s services and Layers guides.