Skip to content

AtomRef & Hydration

This page covers two small, related corners of the reactivity layer:

  • AtomRef — standalone observable state cells that do not go through an AtomRegistry. They are ideal for form state, local view models, and ordered collections where callers need direct mutation methods plus change notifications.
  • Hydration — moving serializable registry state between AtomRegistry instances: snapshot atoms on the server (dehydrate), ship the encoded values, and load them into a client registry (hydrate) before the atoms are first read.

Both import from the unstable reactivity group:

import { AtomRef, Hydration } from "effect/unstable/reactivity"

An AtomRef is a value cell with a stable key and a subscriber list. Unlike atoms, it carries its current value directly and exposes imperative set / update / prop methods — there is no registry to read it through.

import { AtomRef } from "effect/unstable/reactivity"
// A standalone, mutable reactive cell.
const count = AtomRef.make(0)
count.value // => 0
// Subscribe to changes. Returns an unsubscribe function.
const unsubscribe = count.subscribe((n) => console.log("count is now", n))
count.set(1) // logs: count is now 1
count.update((n) => n + 1) // logs: count is now 2
count.value // => 2
unsubscribe()

Three rules drive everything below:

  1. Notifications are equality-aware. Setting a value that is Equal.equals to the current value does not notify subscribers. Derived (map) and focused (prop) subscriptions only emit when their projected value changes.
  2. Direct mutation does not notify. Mutating an object or array stored in a ref in place (count.value.push(...)) will not trigger listeners. Always go through set, update, or a prop ref so a fresh value is produced.
  3. toArray on a collection returns raw values, not refs.

The service/Layer style works here too — keep the local cell on a small view model and expose focused refs to the UI:

import { AtomRef } from "effect/unstable/reactivity"
interface SignupForm {
readonly email: string
readonly password: string
readonly accepted: boolean
}
const form = AtomRef.make<SignupForm>({
email: "",
password: "",
accepted: false
})
// Focused, individually-subscribable field refs.
const email = form.prop("email")
const accepted = form.prop("accepted")
// A derived, read-only validity view.
const isValid = form.map((f) => f.email.includes("@") && f.password.length >= 8)
isValid.subscribe((ok) => console.log("can submit:", ok))
email.set("ada@example.com") // updates form.value.email; isValid recomputes
accepted.update((b) => !b) // => true
form.value
// => { email: "ada@example.com", password: "", accepted: true }

Note that mutating a prop ref rebuilds the parent value immutably (object spread, or array slice for array entries), so the parent’s subscribers fire too.


Part 2 — Hydration (server → client transfer)

Section titled “Part 2 — Hydration (server → client transfer)”

Hydration snapshots atoms marked with Atom.serializable in one AtomRegistry and loads those encoded values into another registry before the atoms are first read. This is the standard SSR / browser-bootstrap handoff: compute on the server, ship the snapshot in the HTML, rehydrate on the client.

Only the stable serialization key, the encoded value, a dehydration timestamp, and an optional async handoff are transferred — atom identity is not. The target registry must contain atoms with matching keys and compatible codecs.

import { Atom, AtomRegistry, Hydration } from "effect/unstable/reactivity"
import { Schema } from "effect"
// A serializable atom. The same definition must exist on both ends.
const userId = Atom.make(0).pipe(
Atom.serializable({ key: "userId", schema: Schema.Number })
)
// --- Server ---
const server = AtomRegistry.make()
server.set(userId, 42)
// Snapshot only the serializable atoms.
const snapshot = Hydration.dehydrate(server)
// => [{ "~effect/reactivity/DehydratedAtom": true,
// key: "userId", value: 42, dehydratedAt: 171... }]
// Serialize and embed in HTML, e.g.
// `<script>window.__STATE__ = ${JSON.stringify(snapshot)}</script>`
// --- Client ---
const client = AtomRegistry.make()
Hydration.hydrate(client, snapshot)
// The atom now reads the server-computed value on first access.
client.get(userId) // => 42

The literal/runtime tag identifying every AtomRef value: "~effect/reactivity/AtomRef".

import { AtomRef } from "effect/unstable/reactivity"
const ref = AtomRef.make(0)
ref[AtomRef.TypeId] // => "~effect/reactivity/AtomRef"

A read-only reactive reference. Exposes a stable key, the current value, subscribe, and map. Equality and hashing are based on the current value (it implements Equal.Equal). map results and other read-only views are typed as ReadonlyRef.

import { AtomRef } from "effect/unstable/reactivity"
import { Equal } from "effect"
const a = AtomRef.make(1)
const view: AtomRef.ReadonlyRef<number> = a.map((n) => n * 10)
view.value // => 10
view.key // => stable string id, e.g. "AtomRef-1"
// Equality compares the underlying value.
Equal.equals(AtomRef.make(1), AtomRef.make(1)) // => true

A mutable reactive reference: everything in ReadonlyRef plus prop, set, and update.

import { AtomRef } from "effect/unstable/reactivity"
const ref: AtomRef.AtomRef<{ x: number }> = AtomRef.make({ x: 0 })
ref.set({ x: 1 }).update((v) => ({ x: v.x + 1 }))
ref.value // => { x: 2 }

A ReadonlyRef<ReadonlyArray<AtomRef<A>>> whose items are themselves mutable refs. Adds push, insertAt, remove, and toArray. Changes to an item ref also notify the collection’s subscribers.

import { AtomRef } from "effect/unstable/reactivity"
const todos: AtomRef.Collection<string> = AtomRef.collection(["buy milk"])
todos.value.length // => 1
todos.value[0].value // => "buy milk"

Creates a mutable AtomRef<A> initialized with the supplied value.

import { AtomRef } from "effect/unstable/reactivity"
const name = AtomRef.make("Ada")
name.value // => "Ada"
name.set("Grace").value // => "Grace"

Creates a Collection<A> from an iterable of initial values. Each item is wrapped in its own AtomRef.

import { AtomRef } from "effect/unstable/reactivity"
const list = AtomRef.collection([1, 2, 3])
list.toArray() // => [1, 2, 3]

The current value of the ref. On map/prop refs it is computed lazily from the parent on access.

import { AtomRef } from "effect/unstable/reactivity"
const n = AtomRef.make(41)
n.value // => 41
n.set(42)
n.value // => 42

A stable, generated string identifier for the ref ("AtomRef-N"). Useful as a React key or for debugging; it does not change across set/update.

import { AtomRef } from "effect/unstable/reactivity"
const r = AtomRef.make(0)
r.key // => "AtomRef-0" (counter value varies)

Registers a listener and returns an unsubscribe function. The listener fires on every change whose new value is not Equal.equals to the previous one.

import { AtomRef } from "effect/unstable/reactivity"
const r = AtomRef.make(0)
const stop = r.subscribe((v) => console.log("changed to", v))
r.set(0) // no log — Equal.equals to current
r.set(1) // logs: changed to 1
stop() // detach the listener

Derives a read-only ReadonlyRef<B>. The derived subscription only emits when the projected value actually changes, so it doubles as a memoized selector.

import { AtomRef } from "effect/unstable/reactivity"
const user = AtomRef.make({ name: "Ada", age: 36 })
const name = user.map((u) => u.name)
name.subscribe((n) => console.log("name:", n))
user.set({ name: "Ada", age: 37 }) // no log — name unchanged
user.set({ name: "Grace", age: 37 }) // logs: name: Grace
name.value // => "Grace"

Focuses a mutable AtomRef on a nested object key or array index. Reads pull from the parent; writes rebuild the parent value immutably and notify both the prop ref and the parent. prop chains, so you can drill into deeply nested structures.

import { AtomRef } from "effect/unstable/reactivity"
const state = AtomRef.make({ user: { name: "Ada" }, tags: ["a", "b"] })
const name = state.prop("user").prop("name")
name.set("Grace")
state.value.user.name // => "Grace"
// Array entries focus by numeric index.
const firstTag = state.prop("tags").prop(0)
firstTag.update((t) => t.toUpperCase())
state.value.tags // => ["A", "b"]

Replaces the whole value and notifies subscribers (unless equal to the current value). Returns the same ref for chaining.

import { AtomRef } from "effect/unstable/reactivity"
const r = AtomRef.make(1)
r.set(2).set(3).value // => 3

Computes the next value from the current one via set. Same notify/equality semantics; returns the ref.

import { AtomRef } from "effect/unstable/reactivity"
const r = AtomRef.make(10)
r.update((n) => n * 2).value // => 20

Appends a new item (wrapped in its own ref) to the end and notifies. Returns the collection.

import { AtomRef } from "effect/unstable/reactivity"
const list = AtomRef.collection<number>([1])
list.push(2).push(3)
list.toArray() // => [1, 2, 3]

Inserts a new item ref at the given index and notifies. Returns the collection.

import { AtomRef } from "effect/unstable/reactivity"
const list = AtomRef.collection(["a", "c"])
list.insertAt(1, "b")
list.toArray() // => ["a", "b", "c"]

Removes the given item ref by identity (no-op if not present) and notifies. Returns the collection.

import { AtomRef } from "effect/unstable/reactivity"
const list = AtomRef.collection(["x", "y"])
const yRef = list.value[1]
list.remove(yRef)
list.toArray() // => ["x"]

Returns a fresh array of the current raw item values (not the refs). Mutating an individual item ref also fires the collection’s subscribers.

import { AtomRef } from "effect/unstable/reactivity"
const list = AtomRef.collection([{ done: false }])
list.subscribe(() => console.log("collection changed"))
// Mutating an item ref notifies the collection.
list.value[0].update((t) => ({ done: true })) // logs: collection changed
list.toArray() // => [{ done: true }]

Marker interface for entries in a dehydrated registry snapshot. Carries the tag "~effect/reactivity/DehydratedAtom": true. dehydrate returns an array of these; use toValues to view them as the concrete record shape.

import { Hydration } from "effect/unstable/reactivity"
const entries: ReadonlyArray<Hydration.DehydratedAtom> = []

The concrete snapshot record: the atom’s serialization key, the encoded value, the dehydratedAt timestamp, and an optional resultPromise (used only for AsyncResult.Initial values encoded with encodeInitialAs: "promise").

import { Hydration } from "effect/unstable/reactivity"
const entry: Hydration.DehydratedAtomValue = {
"~effect/reactivity/DehydratedAtom": true,
key: "userId",
value: 42,
dehydratedAt: Date.now()
}

Encodes the serializable atoms currently held by a registry into an array of DehydratedAtom. Atoms not marked with Atom.serializable are skipped.

The encodeInitialAs option controls how AsyncResult.Initial values are handled:

  • "ignore" (default) — skip atoms still in the initial state.
  • "value-only" — encode the initial value as-is.
  • "promise" — attach a live resultPromise that resolves when the atom leaves the initial state, so a still-loading server fetch can complete after the snapshot is taken.
import { AtomRegistry, Hydration } from "effect/unstable/reactivity"
const registry = AtomRegistry.make()
// Default: ignore atoms still in AsyncResult.Initial.
Hydration.dehydrate(registry) // => Array<DehydratedAtom>
// Wait for in-flight initial results to settle before they ship.
Hydration.dehydrate(registry, { encodeInitialAs: "promise" })

Narrows an array of generic DehydratedAtom entries to the concrete DehydratedAtomValue records, so you can read key / value / dehydratedAt.

import { AtomRegistry, Hydration } from "effect/unstable/reactivity"
const state = Hydration.dehydrate(AtomRegistry.make())
const values = Hydration.toValues(state)
values.map((v) => v.key) // => string[] of serialization keys

Applies dehydrated state to a target registry by serialization key. Each entry is preloaded with registry.setSerializable, so the matching atom decodes the value with its own codec on first read. Entries carrying a resultPromise update the registry node (or preload the resolved value) once the promise resolves.

import { AtomRegistry, Hydration } from "effect/unstable/reactivity"
const client = AtomRegistry.make()
const snapshot = [
{
"~effect/reactivity/DehydratedAtom": true as const,
key: "userId",
value: 42,
dehydratedAt: Date.now()
}
]
Hydration.hydrate(client, snapshot)
// matching `userId` atom now reads 42 before any computation runs
  • Atom — registry-backed reactive values.
  • Atom combinatorsserializable, withServerValue, and friends used by hydration.
  • AsyncResult — the result type whose Initial/loading states the encodeInitialAs option handles.
  • Registry — the AtomRegistry that hydration reads from and writes to.