Browse documentation

State

Share a reactive capability through Context

Give independently built routes, commands, and views one selection model without exposing unnecessary write authority.

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 needsContractExample
Observe valuesFx.ServiceRead a transport’s incoming events
Submit valuesSink.ServiceSend audit records to an owner
Publish and observe eventsSubject.ServiceShared notification bus without current state
Submit one type and observe anotherPush.ServiceCommand input paired with a reply stream
Read an external snapshot, updates, and versionVersioned.ServiceExternal store adapter
Read, observe, and replace stateRefSubject.ServiceInternal feature model trusted by its consumers
Read state and invoke constrained commandsCustom Context serviceSelection 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.