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.Serviceshape), - an
AtomRuntimethat provides the transport to every atom it creates, query(...)— reads become anAtomofAsyncResult(or a writable pull atom for streaming RPCs), andmutation(...)— writes become anAtomResultFn: 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:
| Module | Source of truth | Read returns | Write returns |
|---|---|---|---|
AtomRpc | an RpcGroup (RPCs) | Atom<AsyncResult> — or a pull atom if streaming | AtomResultFn |
AtomHttpApi | an HttpApi (HTTP endpoints) | Atom<AsyncResult> | AtomResultFn |
Part 1 — AtomRpc
Section titled “Part 1 — AtomRpc”The common case
Section titled “The common case”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 itTodos 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)})Streaming RPCs become pull atoms
Section titled “Streaming RPCs become pull atoms”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>AtomRpc reference
Section titled “AtomRpc reference”AtomRpc.Service<Self>()(id, options)
Section titled “AtomRpc.Service<Self>()(id, options)”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, .mutationThe full options object:
group(RpcGroup<Rpcs>, required) — the RPC group to expose.protocol(required) — the client transportLayer, or(get: AtomContext) => Layer. Provide a serialization layer (RpcSerialization.layerJson, …) and anHttpClient/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 customEffectproducing the flattened client instead of letting the service build one fromgroup+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}) {}.query(tag, payload, options?)
Section titled “.query(tag, payload, options?)”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>orRecord<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 +RpcClientErrorschemas. Choose stable, unique keys.
const todos = Todos.query("GetTodos", undefined, { reactivityKeys: ["todos"], timeToLive: "30 seconds", serializationKey: "todos:all"}).mutation(tag)
Section titled “.mutation(tag)”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 | ...>.runtime
Section titled “.runtime”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<...>, ...>>AtomRpc.AtomRpcClient<Self, Id, Rpcs>
Section titled “AtomRpc.AtomRpcClient<Self, Id, Rpcs>”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.
Part 2 — AtomHttpApi
Section titled “Part 2 — AtomHttpApi”The common case
Section titled “The common case”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 itResponse modes
Section titled “Response modes”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 rawHttpClientResponse(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.
AtomHttpApi reference
Section titled “AtomHttpApi reference”AtomHttpApi.Service<Self>()(id, options)
Section titled “AtomHttpApi.Service<Self>()(id, options)”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, .mutationOptions:
api(HttpApi<Id, Groups>, required) — the API definition.httpClient(required) — aLayerproviding the API’s client services and anHttpClient, or(get: AtomContext) => Layerto derive it from atom context.baseUrl(URL | string) — base URL prepended to endpoint paths.transformClient((client) => client) — cross-cuttingHttpClienttransform (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(...))}) {}.query(group, endpoint, request)
Section titled “.query(group, endpoint, request)”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"}).mutation(group, endpoint, options?)
Section titled “.mutation(group, endpoint, options?)”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"}).runtime
Section titled “.runtime”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 theReactivityservice 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 finiteDurationbecomes the atom’s idle TTL (Atom.setIdleTTL); an infinite one keeps it alive (Atom.keepAlive).serializationKey(queries) / decoded-only mode (mutations) — wrap the atom withAtom.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"})