Browse documentation

State

Derived values and optional state

Derive read-only values and make loss of selection observable with Computed and Filtered.

A selected-ID array always has a count. A focused ID may be missing. These queries need different contracts: one always produces a value, while the other must decide whether absence is skipped or published so a consumer can clear its output.

Computed and Filtered are read-only views over a Versioned source. They can be read as Effects or observed as Fx; they do not create another writable truth. Start with state composition for the owning model.

ViewCurrent readObservation
Computed<A, E, R>A value, or EDerived updates
Filtered<A, E, R>A value, E, or NoSuchElementErrorPresent derived updates; absence is skipped

A current read and an observation handle absence differently. Choose the view for what its consumer needs to observe, not just the type of its successful value.

Compute a value that always exists

A selected-ID array always has a length, including when it is empty. map is the appropriate query.

import { Effect } from "effect"
import { RefSubject } from "@typed/fx"

const example = Effect.scoped(Effect.gen(function* () {
  const selected = yield* RefSubject.make<ReadonlyArray<string>>([])

  const count = RefSubject.map(selected, (ids) => ids.length)
  const empty = RefSubject.map(count, (value) => value === 0)

  yield* RefSubject.set(selected, ["42", "43"])

  return { count: yield* count, empty: yield* empty }
}))

There is no set(count, ...). The model changes selected IDs and the count stays derived. The projection is lazy: creating the view does not run it. Current reads and observations apply it when a value is needed. The example returns { count: 2, empty: false }.

Prefer pure projection for formatting and totals. mapEffect can do effectful work, but it does not by itself define a feature-wide shared request cache. If several consumers must share one remote request, give that producer an owner and retain its AsyncData result once, then derive cheap views.

Preserve loss of selection when the consumer needs it

The focused ID is optional. A command that requires an ID can use a Filtered. A pane that must clear when focus is removed must observe both Some and None.

import { Effect, Option } from "effect"
import { RefSubject } from "@typed/fx"

const example = Effect.scoped(Effect.gen(function* () {
  const focusedId = yield* RefSubject.make(Option.none<string>())

  const present = RefSubject.compact(focusedId)
  const label = RefSubject.getOrElse(present, () => "No focused issue")
  const explicitAbsence = present.asComputed()

  yield* RefSubject.set(focusedId, Option.some("42"))
  const selectedLabel = yield* label

  yield* RefSubject.set(focusedId, Option.none())

  return { selectedLabel, emptyLabel: yield* label, current: yield* explicitAbsence }
}))

compact turns Option-valued state into a Filtered. filterMap does the same when a projection may return None; filterMapEffect adds Effectful decision work. asComputed() exposes Option again, while getOrElse publishes a meaningful fallback and removes only the absence failure.

If a loader observes only present, it sees "42" and no event for deselection. It therefore cannot infer that its old request or output should disappear. Observe the Option-valued source at a selection boundary and switch on both cases. This is not a renderer quirk: skipping an emission is different from emitting an empty result in any reactive system.

Keep projection failures and services visible

mapEffect and filterMapEffect can add errors and service requirements to the source’s channels. Services used by a projection must be provided when the read or observation runs. See services and lifetime for provisioning patterns.

Filtered adds NoSuchElementError to its current-read Effect when the result is absent. Its Fx observation skips that absence instead of failing. Other projection errors still propagate.

Test observations independently from snapshots

A snapshot test reads the count, changes IDs, and reads it again. An observation test must actually subscribe before the writes it expects to see. After forking the observer, use Effect.sleep(0) to let it run before writing.

import { Effect, Fiber, Option } from "effect"
import { expect, it } from "vitest"
import { Fx, RefSubject } from "@typed/fx"

it("emits selections while skipping absence", () => Effect.scoped(Effect.gen(function* () {
  const source = yield* RefSubject.make<Option.Option<string>>(Option.none())
  const present = RefSubject.compact(source)

  const observed = yield* Fx.collectUpToFork(present, 2)
  yield* Effect.sleep(0)

  yield* RefSubject.set(source, Option.some("42"))
  yield* RefSubject.set(source, Option.none())
  yield* RefSubject.set(source, Option.some("43"))

  expect(yield* Fiber.join(observed)).toEqual(["42", "43"])
})).pipe(Effect.runPromise))

This test demonstrates Filtered’s omission, not just a successful selection. A pane-clearing test would instead observe Option and assert None. Current-read tests should also cover absence and projection errors. A passing DOM assertion after one selection cannot establish these contracts.

When the question is about history

scan and scanEffect fold source history; they are not a count of current selection and not a reliable count of commands when equality suppresses repeated state commits. Use an event source when every occurrence matters, or retain one owned accumulator when readers must share its history. See Subject events for occurrences and the API reference for the full scan/scanEffect contract.