Skip to content

Server state: AtomRpc & AtomHttpApi

AtomRpc and AtomHttpApi turn remote endpoints into atoms. Instead of calling a client by hand and threading the result into your own state, you describe a service once and get back:

  • a generated, typed client (the Context.Service shape),
  • an AtomRuntime that provides the transport to every atom it creates,
  • query(...) — reads become an Atom of AsyncResult (or a writable pull atom for streaming RPCs), and
  • mutation(...) — writes become an AtomResultFn: write the request, observe the result.

Both modules speak the same reactivity vocabulary: queries take reactivityKeys so they refresh when those keys are invalidated, mutations invalidate the same keys after they succeed (see Reactivity & invalidation), and non-streaming results can carry a serializationKey for SSR hydration (see AtomRef & hydration).

The two modules differ only in transport:

ModuleSource of truthRead returnsWrite returns
AtomRpcan RpcGroup (RPCs)Atom<AsyncResult> — or a pull atom if streamingAtomResultFn
AtomHttpApian HttpApi (HTTP endpoints)Atom<AsyncResult>AtomResultFn

Define an RpcGroup, then extend AtomRpc.Service<Self>() with the group and a client protocol layer. The resulting class is both the client service and the carrier of query / mutation atoms.

import { Effect, Layer, Schema } from "effect"
import { AtomRpc } from "effect/unstable/reactivity"
import { Rpc, RpcClient, RpcGroup } from "effect/unstable/rpc"
import { RpcSerialization } from "effect/unstable/rpc"
import { FetchHttpClient } from "effect/unstable/http"
// 1. Describe the RPCs (payload + success schemas).
const GetTodos = Rpc.make("GetTodos", {
success: Schema.Array(Schema.Struct({ id: Schema.Number, text: Schema.String }))
})
const CreateTodo = Rpc.make("CreateTodo", {
payload: { text: Schema.String },
success: Schema.Struct({ id: Schema.Number, text: Schema.String })
})
const TodosRpc = RpcGroup.make(GetTodos, CreateTodo)
// 2. Bind the group to an atom runtime via a transport `protocol` layer.
class Todos extends AtomRpc.Service<Todos>()("app/Todos", {
group: TodosRpc,
protocol: Layer.provide(
RpcClient.layerProtocolHttp({ url: "/api/rpc" }),
[RpcSerialization.layerJson, FetchHttpClient.layer]
)
}) {}
// 3. Reads -> an Atom of AsyncResult.
const todosAtom = Todos.query("GetTodos", undefined, {
reactivityKeys: ["todos"]
})
// => Atom<AsyncResult<ReadonlyArray<{ id, text }>, RpcClientError>>
// 4. Writes -> an AtomResultFn. Invalidating "todos" reruns todosAtom.
const createTodo = Todos.mutation("CreateTodo")
// write { payload: { text: "..." }, reactivityKeys: ["todos"] } to run it

Todos is itself a Context.Service whose value is the flattened RpcClient, so you can also call the client directly inside Effect code when you do not need an atom:

const program = Effect.gen(function* () {
const client = yield* Todos
return yield* client("GetTodos", undefined)
})

If an RPC’s success schema is a stream (Rpc.make(..., { stream: true })), query returns a writable pull atom instead of an Atom<AsyncResult>. Write void to it to advance the stream; it is not serializable.

import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const Events = Rpc.make("Events", {
success: Schema.String,
error: Schema.Never,
stream: true
})
const StreamGroup = RpcGroup.make(Events)
// Streams.query("Events", undefined)
// => Writable<PullResult<string, RpcClientError>, void>

Creates the Context.Service class. <Self>() is the usual two-step self-reference; the second call takes a unique string id and the options below.

class Todos extends AtomRpc.Service<Todos>()("app/Todos", {
group: TodosRpc,
protocol: myProtocolLayer
}) {}
// => class Todos (a Context.Service) with .runtime, .query, .mutation

The full options object:

  • group (RpcGroup<Rpcs>, required) — the RPC group to expose.
  • protocol (required) — the client transport Layer, or (get: AtomContext) => Layer. Provide a serialization layer (RpcSerialization.layerJson, …) and an HttpClient/socket/worker layer to it.
  • spanPrefix (string) — prefix for client request spans.
  • spanAttributes (Record<string, unknown>) — extra attributes added to request spans.
  • generateRequestId (() => RequestId) — override request id generation.
  • disableTracing (boolean) — turn off client tracing.
  • makeEffect — supply a custom Effect producing the flattened client instead of letting the service build one from group + protocol.
  • runtime (Atom.RuntimeFactory) — use a non-default runtime factory (e.g. one with shared global layers / MemoMap).
class Todos extends AtomRpc.Service<Todos>()("app/Todos", {
group: TodosRpc,
protocol: myProtocolLayer,
spanPrefix: "TodosClient",
disableTracing: false
}) {}

Builds (and memoizes) an atom for a single RPC read. Non-streaming RPCs return Atom<AsyncResult<Success, Error | RpcClientError | MiddlewareError>>; streaming RPCs return a writable pull atom. The atom’s identity is keyed on the payload, normalized headers, reactivity keys, TTL, and serialization key — so keep those stable when atom identity matters.

const byId = Todos.query("GetTodos", undefined)
// => Atom<AsyncResult<ReadonlyArray<...>, RpcClientError>>

options:

  • headers (Headers.Input) — per-request headers; part of the cache key.
  • reactivityKeys (ReadonlyArray<unknown> or Record<string, ReadonlyArray<unknown>>) — refresh this atom whenever any of these keys is invalidated.
  • timeToLive (Duration.Input) — idle TTL. A finite duration sets the atom’s idle TTL; an infinite duration keeps it alive.
  • serializationKey (string) — marks the (non-streaming) atom serializable for hydration. Codecs are derived from the RPC success schema and the combined RPC + middleware + RpcClientError schemas. Choose stable, unique keys.
const todos = Todos.query("GetTodos", undefined, {
reactivityKeys: ["todos"],
timeToLive: "30 seconds",
serializationKey: "todos:all"
})

Builds an AtomResultFn for a single RPC write. Calling it with a tag is memoized, so the same tag yields the same atom. Streaming RPCs are rejected at the type level (never). Mutations are always serializable in decoded form (key AtomRpc:mutation:<tag>).

The write argument is an object:

  • payload — the RPC payload (constructed from the RPC’s payload schema).
  • reactivityKeys (array or record) — invalidated after the mutation succeeds, rerunning any query registered on those keys.
  • headers (Headers.Input) — per-request headers.
const create = Todos.mutation("CreateTodo")
// write to it (via your UI binding / registry):
// create -> { payload: { text: "Buy milk" }, reactivityKeys: ["todos"] }
// => AtomResultFn whose result is AsyncResult<{ id, text }, RpcClientError | ...>

The Atom.AtomRuntime backing this service. Use it to derive additional atoms that need the same client/transport in scope, or to mount the runtime layer.

import { Atom } from "effect/unstable/reactivity"
// A derived read that uses the client directly through the runtime.
const firstTodo = Todos.runtime.atom(
Todos.use((client) => client("GetTodos", undefined))
)
// => Atom<AsyncResult<ReadonlyArray<...>, ...>>

The interface describing the generated service: it extends the flattened RpcClient Context.Service and adds runtime, query, and mutation. You rarely name it directly — class X extends AtomRpc.Service<X>()(...) produces a value of this type.


AtomHttpApi wraps HttpApiClient.make, so you supply an HttpApi definition, an HttpClient layer, and (optionally) a baseUrl. Every endpoint becomes available as a method on the generated client, and as query / mutation atoms.

import { Schema } from "effect"
import { AtomHttpApi } from "effect/unstable/reactivity"
import { FetchHttpClient } from "effect/unstable/http"
import {
HttpApi,
HttpApiEndpoint,
HttpApiGroup
} from "effect/unstable/httpapi"
// 1. Describe the API. In v4, success/payload schemas are passed in the
// endpoint options object (not via builder methods).
const Todo = Schema.Struct({ id: Schema.Number, text: Schema.String })
const TodosGroup = HttpApiGroup.make("todos")
.add(
HttpApiEndpoint.get("list", "/todos", {
success: Schema.Array(Todo)
})
)
.add(
HttpApiEndpoint.post("create", "/todos", {
payload: { text: Schema.String },
success: Todo
})
)
const Api = HttpApi.make("api").add(TodosGroup)
// 2. Bind it to an atom runtime.
class TodosApi extends AtomHttpApi.Service<TodosApi>()("app/TodosApi", {
api: Api,
httpClient: FetchHttpClient.layer,
baseUrl: "/api"
}) {}
// 3. Reads -> an Atom of AsyncResult (decoded by default).
const listAtom = TodosApi.query("todos", "list", {
reactivityKeys: ["todos"]
})
// => Atom<AsyncResult<ReadonlyArray<Todo>, ...endpoint + middleware errors...>>
// 4. Writes -> an AtomResultFn that invalidates "todos" on success.
const createTodo = TodosApi.mutation("todos", "create")
// write { payload: { text: "..." }, reactivityKeys: ["todos"] } to run it

Endpoints support a responseMode controlling what the atom resolves to:

  • "decoded-only" (default) — the decoded success value. Required for serialization.
  • "decoded-and-response"[Success, HttpClientResponse].
  • "response-only" — the raw HttpClientResponse (no decoded success, and no typed endpoint error).
const withResponse = TodosApi.query("todos", "list", {
responseMode: "decoded-and-response"
})
// => Atom<AsyncResult<[ReadonlyArray<Todo>, HttpClientResponse], ...>>

Only "decoded-only" atoms are serializable — avoid serializing modes that expose a raw HttpClientResponse.

Creates the Context.Service class around HttpApiClient.make. The same HttpApi, schemas, base URL, middleware services, and HTTP client layer must be available wherever the runtime is built.

class TodosApi extends AtomHttpApi.Service<TodosApi>()("app/TodosApi", {
api: Api,
httpClient: FetchHttpClient.layer
}) {}
// => class TodosApi with .runtime, .query, .mutation

Options:

  • api (HttpApi<Id, Groups>, required) — the API definition.
  • httpClient (required) — a Layer providing the API’s client services and an HttpClient, or (get: AtomContext) => Layer to derive it from atom context.
  • baseUrl (URL | string) — base URL prepended to endpoint paths.
  • transformClient ((client) => client) — cross-cutting HttpClient transform (e.g. attach default headers, retries, logging).
  • transformResponse ((effect) => effect) — wrap every endpoint effect, e.g. for instrumentation, before decoding.
  • runtime (Atom.RuntimeFactory) — use a non-default runtime factory.
class TodosApi extends AtomHttpApi.Service<TodosApi>()("app/TodosApi", {
api: Api,
httpClient: FetchHttpClient.layer,
baseUrl: "https://api.example.com",
transformClient: (client) => client // e.g. .pipe(HttpClient.retryTransient(...))
}) {}

Builds (and memoizes) an atom for an endpoint read. request carries the endpoint inputs (params, query, payload, headers) plus the reactivity controls. The atom resolves to AsyncResult<Response, EndpointError | MiddlewareError>.

const one = TodosApi.query("todos", "list", {})
// => Atom<AsyncResult<ReadonlyArray<Todo>, ...>>

request extras:

  • reactivityKeys — refresh on key invalidation.
  • timeToLive (Duration.Input) — finite -> idle TTL, infinite -> keep alive.
  • serializationKey (string) — serializable in "decoded-only" mode only; codecs derive from the endpoint’s success/error schemas. Choose keys that uniquely identify the request.
  • responseMode — as described above (defaults to "decoded-only").
const list = TodosApi.query("todos", "list", {
reactivityKeys: ["todos"],
timeToLive: "1 minute",
serializationKey: "todos:list"
})

Builds an AtomResultFn for an endpoint write. The write argument is the endpoint’s decoded ClientRequest (params / query / payload / headers) plus reactivityKeys invalidated on success. options.responseMode defaults to "decoded-only", which also makes the mutation serializable (key AtomHttpApi:mutation:<group>:<endpoint>).

const create = TodosApi.mutation("todos", "create")
// write { payload: { text: "Write docs" }, reactivityKeys: ["todos"] }
// => AtomResultFn whose result is AsyncResult<Todo, ...>
// Opt into the raw response instead:
const createRaw = TodosApi.mutation("todos", "create", {
responseMode: "response-only"
})

The Atom.AtomRuntime backing the service — identical role to AtomRpc’s. Use it to build extra atoms that need the generated client in scope.

import { Atom } from "effect/unstable/reactivity"
const count = TodosApi.runtime.atom(
TodosApi.use((client) => client.todos.list({}))
)
// => Atom<AsyncResult<ReadonlyArray<Todo>, ...>>

AtomHttpApi.AtomHttpApiClient<Self, Id, Groups>

Section titled “AtomHttpApi.AtomHttpApiClient<Self, Id, Groups>”

The interface for the generated service: it extends the HttpApiClient.Client<Groups> Context.Service and adds runtime, query, and mutation. Produced for you by class X extends AtomHttpApi.Service<X>()(...).


Hydration, reactivity, and TTL in one place

Section titled “Hydration, reactivity, and TTL in one place”

The three features that make these modules “server state” rather than plain clients all flow through the underlying Atom machinery, so they behave identically across both modules:

  • reactivityKeys — on a query they register the atom with the Reactivity service so it refreshes when a key is invalidated; on a mutation they are invalidated after success. Keep keys stable across the client and server registries.
  • timeToLive — a finite Duration becomes the atom’s idle TTL (Atom.setIdleTTL); an infinite one keeps it alive (Atom.keepAlive).
  • serializationKey (queries) / decoded-only mode (mutations) — wrap the atom with Atom.serializable, deriving codecs from the endpoint/RPC schemas. This is what lets a server-rendered result be dehydrated and rehydrated on the client; see AtomRef & hydration for moving those serialized values across the boundary, and the serializable combinators on the Atom page for the lower-level primitive.
// A fully-configured server-state read: refreshes on "todos",
// expires after 30s idle, and round-trips through SSR hydration.
const todos = TodosApi.query("todos", "list", {
reactivityKeys: ["todos"],
timeToLive: "30 seconds",
serializationKey: "todos:list"
})