effect-atom-jsx is a reactive UI library built on two complementary foundations: a signal-based reactive graph (Solid.js-compatible) and Effect's typed async/error model. The central thesis is that reactive state and typed effects are better together — atoms give you fine-grained reactivity with zero boilerplate, and Effect gives you a principled, composable way to handle everything async.
Mental model: atoms are the reactive layer; Effect is the async layer. When they meet — in async atoms, actions, and components — Effect's types flow through unmodified.
Atom— The reactive state unit. Callable for reads (atom()), writable variants exposeset/update/modify. Atoms track their own dependencies; derived atoms recompute lazily when upstreams change.Derived atom— Read-only atom computed from other atoms. Created withAtom.make((get) => ...)orAtom.derived(...). Results are cached until dependencies change.QueryRef— Async read handle fromdefineQuery. Bundlesresult,pending,latest,effect, and invalidation APIs into one ergonomic object.Mutation handle— Async write handle fromdefineMutation. Exposesrun,effect,result,pending.Action handle— Runtime-bound mutation handle fromAtom.action/Atom.runtime(...).action. The preferred way to express mutations when you have Effect-native code.Result— The five-state async type (Loading,Refreshing,Success,Failure,Defect). Distinguishing initial load from revalidation and typed failures from defects makes UI states explicit rather than derived.Effect(from theeffectpackage) — A typed programEffect<A, E, R>. The.effect(...)methods on query/mutation handles convert reactive state into composable Effect values.BridgeError— Tagged errors emitted when you compose a reactive atom into an Effect pipeline and the atom is stillLoading(ResultLoadingError) or has aDefect(ResultDefectError). Makes the gap between reactive state and Effect's error channel explicit.MutationSupersededError— Emitted when a newer mutation run interrupts an earlier one. Lets Effect pipelines react to cancellation rather than silently dropping results.AtomRef— Object/collection-centric reactive refs with property-level access. Use when you want per-property subscriptions on a shared object without splitting it into many atoms.OptimisticRef— Temporary overlay state fromcreateOptimistic. Lets UI read an optimistic value while the real mutation is in flight.Store— There is no separate top-level store. UseAtomReffor object/draft-style state orAtom.projection(...)for computed mutable views.
Effect programs carry three type parameters:
A— success valueE— typed error channel (only expected errors belong here)R— required services/context (dependency injection at the type level)
This library preserves all three axes as async state flows from Effect programs into atoms and back. Nothing is silently discarded.
// Effect<User[], HttpError, Api> — three type params preserved
const usersEffect = Effect.gen(function* () {
const api = yield* Api;
return yield* api.listUsers();
});
const rt = Atom.runtime(ApiLive);
const users = rt.atom(usersEffect);
// users() → Result<User[], HttpError> — A and E preserved in Result
// users.effect() → Effect<User[], HttpError | BridgeError> — composable in Effect pipelinesWhy Atom.runtime(layer)? Requirements (R) must be satisfied before an atom can read. The runtime takes a Layer once, satisfies all Rs at creation time, and all atoms/actions created from it share that bound context. This keeps atom definitions portable — you define the effect with R, and bind the layer at the callsite.
const rt = Atom.runtime(ApiLive);
rt.atom(effect) // RReq extends R — type-checked at creation
rt.action(fn) // same requirement safetyWritable vs read-only:
- Writable:
Atom.make(value),Atom.value(value) - Read-only derived:
Atom.make((get) => ...),Atom.derived((get) => ...)
Convenience type aliases:
Atom.ReadonlyAtom<A>(alias ofAtom.Atom<A>)Atom.WritableAtom<A, W = A>(alias ofAtom.Writable<A, W>)Atom.AsyncAtom<A, E>(alias ofAtom.Atom<Result<A, E>>)
This library is built on Effect's dependency injection system. Services are typed capabilities; layers are how you build and provide them. Understanding how they fit together is the key to wiring up a real application.
A service is a typed interface declared as a Context.Tag or ServiceMap.Service. An Effect that requires a service declares it in its R type parameter. A layer (Layer<ROut, E, RIn>) is a recipe that constructs services — it can itself require other services (RIn) and it produces one or more services (ROut).
Layer<ApiService, never, HttpClient>
────────── ───── ──────────
provides error requires
mount(fn, container, layer) and Atom.runtime(layer) are the two places where you hand a composed layer to the framework and satisfy all requirements at once. Everything below that callsite can yield* MyService freely.
These are the services the library owns and provides built-in layers for.
Key-based invalidation and subscription. Used internally by single-flight to decide which loaders to revalidate after a mutation, and exposed via Atom.withReactivity(...) for application-level cache invalidation across module boundaries.
What it provides: invalidate(keys), subscribe(keys, onInvalidate), flush(), lastInvalidated() (test only)
Available layers:
| Layer | Description |
|---|---|
Reactivity.live |
Auto-flushing via microtask scheduler. Use in production. |
Reactivity.test |
Manual flush with lastInvalidated capture. Use in tests. |
// Production
mount(App, document.body, Layer.merge(ApiLive, Reactivity.live));
// Test — flush and inspect what was invalidated
mount(App, document.body, Layer.merge(ApiLive, Reactivity.test));When ReactivityService is present in the layer passed to mount(), it is automatically installed as the global reactivity backend. Single-flight and Atom.withReactivity will use it without any further wiring.
URL state and navigation. Provides a reactive url atom and imperative navigation methods. The router layer is environment-specific — you pick the right one for your deployment context.
What it provides: url atom (ReadonlyAtom<URL>), navigate(to), back(), forward(), preload?(to)
Available layers:
| Layer | Description |
|---|---|
Route.Router.Browser |
Wraps the browser History API. Listens to popstate. Use in client-rendered apps. |
Route.Router.Hash |
Hash-based routing (#/path). Listens to hashchange. Use when you can't control server routing. |
Route.Router.Server(request) |
Static URL from an incoming request. Use during SSR. |
Route.Router.Memory(initial?) |
In-memory history stack. Use in tests and Node environments. |
// Browser app
const AppLayer = Layer.mergeAll(ApiLive, Reactivity.live, Route.Router.Browser);
// SSR handler
const ssrLayer = (req: Request) =>
Layer.mergeAll(ApiLive, Reactivity.live, Route.Router.Server(req));
// Tests
const testLayer = Layer.mergeAll(ApiLive, Reactivity.test, Route.Router.Memory("/users/1"));Per-route context: params, query string, hash, matched flag, and loader data atoms. This service is provided automatically by the router internals when a component mounts inside a matched route. You don't construct it directly — you read from it via Route.params, Route.query, Route.hash, Route.loaderData, and Route.loaderResult.
What it provides: params, query, hash, prefix, matched, loaderData, loaderResult — all as readonly atoms.
Transport contract for mutation single-flight. When present, Atom.action(...) handles with singleFlight options will route mutation requests through this transport instead of executing the local Effect. This enables server-side mutation execution with client-side cache seeding.
What it provides: execute(request, options) — sends a mutation request envelope and receives a payload response.
Available layers:
| Layer | Description |
|---|---|
Route.FetchSingleFlightTransport(options?) |
Default HTTP fetch-based transport. Sends requests to the configured endpoint. |
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
Route.FetchSingleFlightTransport({ endpoint: "/_sf" }),
);When SingleFlightTransportService is present in the layer passed to mount(), it is automatically wired into action handles that declare singleFlight options — no manual plumbing needed.
These three services form a cohesive group and are always provided together via RouterRuntime.toLayer(runtime, history). They exist as separate tags so individual pieces of the router can declare narrower requirements.
| Tag | Provides |
|---|---|
RouterRuntime.HistoryTag |
location(), push(to), replace(to), go(delta) |
RouterRuntime.NavigationTag |
navigate(...), submit(...), fetch(...), revalidate(...), cancel(...) |
RouterRuntime.RouterRuntimeTag |
Full runtime instance: snapshot(), subscribe(), initialize(), and all navigation/dispatch methods |
const runtime = RouterRuntime.create(routes);
const layer = RouterRuntime.toLayer(runtime, historyAdapter);
// layer provides all three tagsDesign tokens and theme mode. Optional — only required if you use Style.tokenColor, Style.tokenSpacing, or Style.tokenFontSize. Without it, token lookups fall back to CSS custom property names.
What it provides: tokens, mode atom ("light" | "dark"), resolve(token).
Available layers:
| Layer | Description |
|---|---|
Theme.ThemeLight |
Default light-mode tokens. |
const AppLayer = Layer.mergeAll(ApiLive, Reactivity.live, Theme.ThemeLight);Provided automatically by Route.renderRequest(...) and ServerRoute.dispatch(...) during SSR execution. You read from them inside server-side component setup and server route handlers via the convenience helpers:
// Inside server component setup or server route handler:
const url = yield* Route.serverUrl; // from ServerRequestTag
const req = yield* Route.serverRequest; // from ServerRequestTag
yield* Route.setStatus(404); // writes to ServerResponseTag
yield* Route.setHeader("Cache-Control", "no-store");
yield* Route.serverRedirect("/login");These tags are never part of your app's mount() layer — they are scoped to a single request/response cycle.
mount(fn, container, layer) does several things beyond just providing services to useService():
- Creates a
ManagedRuntimefrom the layer — this is whatuseService(tag)reads from. - If
ReactivityServiceis in the layer, installs it as the global reactivity backend. - If
SingleFlightTransportServiceis in the layer, installs it for action handle dispatch. - Wraps the tree in a reactive ownership scope — when
mountis disposed, all child effects and subscriptions clean up. - Returns a dispose function.
const dispose = mount(
() => <App />,
document.getElementById("root")!,
Layer.mergeAll(ApiLive, Reactivity.live, Route.Router.Browser),
);
// Later:
dispose(); // shuts down runtime, cleans up event listeners, cancels fiberscreateMount(layer) pre-binds the layer so you can call mount(fn, container) without repeating the layer:
const mount = createMount(AppLayer);
mount(() => <App />, document.getElementById("root")!);Atom.runtime(layer) is the atom-side equivalent. Use it when async atoms or actions need services but the atoms are defined outside a component (e.g., at module level).
// Module-level: declare requirements, bind layer once
const rt = Atom.runtime(ApiLive);
export const currentUser = rt.atom(
Effect.service(UserApi).pipe(Effect.flatMap(api => api.me()))
);
export const saveProfile = rt.action((input: ProfileInput) =>
Effect.service(UserApi).pipe(Effect.flatMap(api => api.save(input)))
);Global layers let you inject cross-cutting services (logging, tracing, feature flags) into every runtime without changing each callsite:
// In your app bootstrap — runs before any runtime is created
Atom.runtime.addGlobalLayer(LoggingLive);
Atom.runtime.addGlobalLayer(TracingLive);
// All subsequent Atom.runtime(layer) calls automatically merge these in
const rt = Atom.runtime(ApiLive);
// rt now has ApiLive + LoggingLive + TracingLiveThis is the right place for observability infrastructure that spans multiple feature modules.
Component.require(...tags) declares which services the component's setup Effect needs. This propagates into the component's Requirements<T> type, which bubbles up to the parent that mounts or wraps the component.
const UserCard = Component.make(
Component.props<{ id: string }>(),
Component.require(UserApi, AnalyticsService), // ← declares requirements
({ id }) => Effect.gen(function* () {
const api = yield* UserApi; // ← resolved at runtime
const analytics = yield* AnalyticsService;
const user = yield* Component.query(api.getUser(id));
yield* analytics.track("user:view", { id });
return { user };
}),
(_, { user }) => <div>{user.result().pipe(...)}</div>,
);Component.withLayer(layer) satisfies requirements at the component level — useful for isolating service implementations to a subtree:
// Provide a mock API to just this component tree
const TestableUserCard = UserCard.pipe(
Component.withLayer(MockUserApiLive),
);If the parent already provides the required services (via mount() or a parent Component.withLayer), no additional wiring is needed.
Standalone (no requirements):
Reactivity.live / .test
Theme.ThemeLight
Route.Router.Browser / .Hash / .Server / .Memory
Route.FetchSingleFlightTransport
Router runtime group (composed via RouterRuntime.toLayer):
RouterRuntime.RouterRuntimeTag
RouterRuntime.HistoryTag ─┐
RouterRuntime.NavigationTag ├─ all from toLayer()
RouterRuntime.RouterRuntimeTag ─┘
Request-scoped (provided per-request by render/dispatch, not by mount):
Route.ServerRequestTag
Route.ServerResponseTag
Your services (you define requirements):
ApiLive → may require HttpClient, Config, etc.
HttpClient.live → standalone (or requires Config)
Minimal browser app:
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
);
mount(() => <App />, document.getElementById("root")!, AppLayer);With single-flight mutations:
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
Route.FetchSingleFlightTransport({ endpoint: "/_sf" }),
);With theme:
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
Theme.ThemeLight,
);Test setup (no browser APIs, manual flush):
const TestLayer = Layer.mergeAll(
MockApiLive,
Reactivity.test,
Route.Router.Memory("/"),
);
const harness = new TestHarness(TestLayer);SSR per-request layer:
function handleRequest(req: Request) {
const layer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Server(req),
);
return Route.renderRequestWithRuntime(runtime, req, { layer });
}Global observability (applied to all atom runtimes):
// app/bootstrap.ts — runs once at startup
Atom.runtime.addGlobalLayer(OtelTracingLive);
Atom.runtime.addGlobalLayer(StructuredLogLive);Effect-native component primitive with typed props, requirements, and errors. Components are Effect programs: their setup phase is an Effect generator that acquires resources, declares local state, runs queries, and wires actions — all in one composable unit.
The key insight is that Component.make separates setup (an Effect that runs once per mount and returns bindings) from view (a reactive function of props and bindings). Setup is where you acquire services, register cleanup, and express async intent. View is purely reactive.
Component.make(props, require, setup, view)Component.headless(props, require, setup)— setup-only, no view (for logic reuse)Component.from(fn)— create from a plain function componentComponent.props<P>()/Component.propsSchema(schema)— declare prop shapeComponent.require(...tags)— declare required Effect services- metadata extractors:
Component.Requirements<T>,Component.Errors<T>,Component.PropsOf<T>,Component.BindingsOf<T> - setup/render bridges:
Component.setupEffect(component, props)andComponent.renderEffect(component, props) - setup helpers (yield inside setup Effect):
Component.state(initial)— local writable atomComponent.derived(fn)— local derived atomComponent.query(effect, options?)— local async query, auto-managed lifetimeComponent.action(fn, options?)— local action, auto-managed lifetimeComponent.ref<T>()— DOM or imperative refComponent.fromDequeue(dequeue, handler)— wire an Effect Queue into component lifetimeComponent.schedule(schedule, run)— run on an Effect ScheduleComponent.scheduleEffect(schedule, effect)— Effect variant
- transforms (pipeable on a Component):
Component.withLayer(layer)— provide additional services to this component subtreeComponent.withErrorBoundary(handlers)— catch typed setup/render errorsComponent.withLoading(fallback)— show fallback while setup Effect is pendingComponent.withSpan(name)— add Effect tracing spanComponent.memo(eq)— memoize by prop equalityComponent.tapSetup(tap)— observe setup bindings for debuggingComponent.withPreSetup(effect)— run an Effect before setup (e.g. prefetch)Component.withSetupRetry(schedule)— retry failed setup on a ScheduleComponent.withSetupTimeout(duration)— fail setup if it takes too long
Component.mount(component, { props, layer, target })— mount to DOM
const Counter = Component.make(
Component.props<{ readonly start: number }>(),
Component.require<never>(),
({ start }) => Effect.gen(function* () {
// Setup runs once — acquire state, queries, actions here
const count = yield* Component.state(start);
const doubled = yield* Component.derived(() => count() * 2);
return { count, doubled };
}),
// View is a reactive function — runs whenever its atom reads change
(_props, { doubled }) => doubled(),
);Composable behavior building blocks for headless UI logic. A Behavior is an Effect program that wires event handling, accessibility, and keyboard interaction onto abstract "slots" (Element handles) without knowing anything about rendering. This lets the same behavior — say, Behaviors.disclosure — work whether you render a <button> or a custom element.
The Element.* constructors define what capability a slot needs (is it interactive? focusable? a text input?). Behaviors are then attached by matching slots to those capabilities.
Behavior.make(run)— define a behavior as an Effect programBehavior.compose(a, b, ...)— merge multiple behaviors into oneBehavior.decorator(behavior)— behavior that wraps anotherBehavior.attach(behavior, { select, merge? })— attach behavior to elements by slot nameBehavior.attachBySlots(behavior, elementMap, merge?)— explicit slot → element wiring
Element capability constructors:
Element.interactive()/Element.container()/Element.focusable()/Element.textInput()/Element.draggable()Element.collection(items)—forEachandobserveEachfor dynamic collection lifecycle
Component slot integration:
Component.withBehavior(behavior, selectElements, merge?)Component.slotInteractive()/Component.slotContainer()/Component.slotFocusable()/Component.slotTextInput()/Component.slotDraggable()/Component.slotCollection(items?)
Built-in behaviors:
Behaviors.disclosure— open/close toggle with accessibilityBehaviors.selection(options?)— single/multi select with keyboard navigationBehaviors.searchFilter(options)— live search/filter over a collectionBehaviors.keyboardNav(options?)— arrow key navigationBehaviors.pagination(options?)— page-based navigation over a collectionBehaviors.focusTrap()— constrain tab focus within a regionBehaviors.combobox(options)— combined input + dropdown behavior
Headless factory helpers:
Composables.createCombobox(options)— composable combobox without a Component
Typed style composition that treats CSS as data. Styles are assembled as structured slot objects, not string templates, so they can be composed, overridden, and attached to component slots safely.
Style composition primitives:
Style.slot,Style.compose,Style.when,Style.states,Style.responsive- animated:
Style.animation,Style.keyframes,Style.transition - advanced:
Style.nest,Style.vars,Style.pseudo,Style.extends(slot) - selectors:
Style.child,Style.descendant,Style.sibling,Style.attr,Style.not,Style.is - animation helpers:
Style.animate,Style.enter,Style.exit,Style.enterStagger,Style.layoutAnimation - at-rules:
Style.media,Style.supports,Style.container,Style.containerQuery,Style.containerType - grid/layers/global:
Style.grid,Style.layers,Style.inLayer,Style.global,Style.globalLayer
Style maps and attachment:
Style.make— create a style map (slot name → style)Style.attach,Style.attachBySlots— attach style maps to element slotsStyle.attachBySlotsFor<Bindings>()— type-safe attach that validates slot names against component bindings
Variants and recipes — type-safe prop-driven style variation:
Style.variants,Style.recipeStyle.VariantProps<T>,Style.RecipeProps<T>— infer prop types from a recipe
Design tokens:
Style.tokenColor,Style.tokenSpacing,Style.tokenFontSizeStyle.override,Style.Provider— runtime token overrides at subtree boundaries
Theme service:
Theme.Themeservice key — inject into Effect layer for system theme accessTheme.ThemeLight— default layerTheme.lookupToken(tokens, path)— resolve a token path to a value
Utility helpers (src/style-utils.ts):
StyleUtils.padded,StyleUtils.rounded,StyleUtils.elevated,StyleUtils.borderedStyleUtils.textStyle,StyleUtils.flexRow,StyleUtils.flexColStyleUtils.interactive,StyleUtils.truncated
Styled composables (src/styled-composables.ts):
StyledComposables.createStyledCombobox— styled + behaviors wired together
Example: examples/styled-combobox/App.tsx
Routing uses a unified route-first model built around Component.pipe(Route.path(...), ...). The intended authoring flow is:
const UserRoute = Component.from<{}>(() => null).pipe(
Route.path("/users/:userId"),
Route.paramsSchema(Schema.Struct({ userId: Schema.String })),
Route.loader((params) => Effect.succeed({ id: params.userId })),
Route.title((params, data) => `${params.userId}:${data?.id ?? "none"}`),
)The key design goal is that route metadata accumulates on a first-class route value, with strong inference flowing through the pipe chain.
Component wrappers like Component.withLoading(...), Component.withSpan(...), and Component.withLayer(...) preserve route metadata and extraction behavior, so helpers like Route.link(...) and Route.ParamsOf<T> survive more safe composition chains.
Unified route pipe:
Route.path(pattern)— attach a URL pattern to a component and return a first-class route valueRoute.paramsSchema,Route.querySchema,Route.hashSchema— replace raw URL inference with decoded schema outputRoute.id,Route.layout(),Route.index(),Route.children(...)— refine the unified route valueRoute.loader,Route.title,Route.meta,Route.guard,Route.transition— accumulate route behavior and metadata on the same route value
Route accessors (inside components):
Route.params— typed URL params atomRoute.query— typed query string atomRoute.hash— typed hash atomRoute.prefix— matched prefix atomRoute.loaderData— loader result atom (success value)Route.loaderResult— loader result atom (Resultunion, for explicit state handling)
Pattern utilities:
Route.matchPattern,Route.extractParams,Route.resolvePattern,Route.matches(pattern)
Links:
Route.link(routedComponent)— create a typed link helper for a routed componentRoute.Link— generic link component
Query sync:
Route.queryAtom(key, schema, { default })— atom backed by a URL query parameter; writes update the URL, reads come from it
Loader infrastructure:
Route.loader— declare loader data and loader effect on a routeRoute.loaderError,Route.prefetch,Route.reload,Route.actionRoute.runMatchedLoaders— run all matched loaders; accepts either aURLor(root, url)
Extraction helpers:
Route.RouteNodeParamsOf<T>,Route.RouteNodeQueryOf<T>,Route.RouteNodeHashOf<T>,Route.RouteNodeLoaderDataOf<T>,Route.RouteNodeLoaderErrorOf<T>- Aliases:
Route.ParamsOf<T>,Route.QueryOf<T>,Route.HashOf<T>,Route.LoaderDataOf<T>,Route.LoaderErrorOf<T>
Route tree introspection/validation:
Route.nodes(...),Route.parentOf(...),Route.ancestorsOf(...),Route.depthOf(...),Route.routeChainOf(...),Route.fullPathOf(...),Route.paramNamesOf(...)Route.validateTree(...)— validate the route tree; reports conflicting sibling patterns
Metadata precedence:
- Title: deepest matched route wins
- Meta: merged root → leaf (deeper keys override parent)
- Callback forms for
Route.title/Route.metareceive(params, loaderData, loaderResult) - Route head callbacks stay reactive after setup and recompute on match/params/loader changes
Extra route pipes/utilities:
Route.guard,Route.title,Route.meta,Route.transition,Route.lazyRoute.Switch,Route.collect,Route.collectAll,Route.validateLinks
SSR/SSG loader helpers:
Route.serializeLoaderData,Route.deserializeLoaderData,Route.streamDeferredLoaderScriptsRoute.collectSitemapEntries,Route.sitemapParams— sitemap collection accepts eitherbaseUrlalone or(root, baseUrl)for explicit trees
Head/meta utilities:
Route.mergeRouteMetaChain,Route.resolveRouteHead,Route.applyRouteHeadToDocument
Router layers:
Route.Router.Browser,Route.Router.Hash,Route.Router.Server(request),Route.Router.Memory(initial?)
Streaming:
Route.runStreamingNavigation— orchestrate streamed navigation responses
Single-flight solves the double round-trip problem. A normal mutation flow requires two network requests: one to execute the mutation, and a second to reload the route data that changed. With single-flight, a single request carries both the mutation execution and the refreshed loader payloads back to the client. The client seeds its loader cache directly from the response — no second fetch, no loading flash.
Without single-flight:
Client → POST /api/save → Server executes mutation
Client ← { ok: true }
Client → GET /users/123 → Server runs loader
Client ← { user: {...} } ← UI updates
With single-flight:
Client → POST /_sf/save → Server executes mutation + runs affected loaders
Client ← { mutation: {...}, loaders: [{ routeId, result }] }
Client seeds cache, UI updates ← no second request
1. Client: action fires, transport intercepts
When Atom.action has a singleFlight option, every run(input) call checks for a transport:
- First checks for
SingleFlightTransportServicein the Effect runtime (installed viamount()layer). - Falls back to a globally-installed transport (set at bootstrap via
Atom.runtime.addGlobalLayer). - Falls back to a direct
fetchtosingleFlight.endpointif no transport service is present. - If
singleFlight.mode === "force"and no transport exists, the Effect fails rather than silently running locally.
const saveUser = Atom.action(
(input: { readonly id: string; readonly name: string }) => api.saveUser(input),
{
reactivityKeys: { users: ["list"], user: ["by-id", "profile"] },
singleFlight: {
endpoint: "/_single-flight/users/save",
// Optional: compute the target URL from the input (for loader matching on the server)
url: (input) => `/users/${input.id}`,
},
},
);The transport sends a request envelope:
{
name?: string; // optional mutation name for server-side routing
args: unknown[]; // the mutation arguments
url: string; // current or computed target URL
}2. Server: mutation runs, loaders are selected and run
Route.singleFlight(fn, options) is the recommended server API. It handles the full server-side flow:
// server/routes/users.ts
const saveUserHandler = Route.singleFlight(
(input: { readonly id: string; readonly name: string }) => api.saveUser(input),
{
target: (result) => `/users/${result.id}`, // URL for loader matching
setLoaders: Route.seedLoader(UserRoute), // seed cache directly
},
);Internally, Route.singleFlight is composed from two lower-level pieces — understanding them explains what happens at each step:
Route.actionSingleFlight(fn, options) — the mutation runner:
- Begins capturing reactivity invalidations (via
Reactivity.tracked). - Executes the mutation function.
- Collects any invalidation keys the mutation emitted.
- Calls
Route.runMatchedLoaders(targetUrl, { reactivityKeys: capturedKeys })to select and execute loaders. - Applies direct loader seeding from
options.setLoaders. - Returns a
SingleFlightPayload.
Route.createSingleFlightHandler(run, options) — the request/response adapter:
- Receives the request envelope from the client.
- Provides a
Route.Router.Server(url)layer to the runner so loaders resolve against the right URL. - Wraps the result in a
SingleFlightResponseenvelope:{ ok: true, payload }or{ ok: false, error }.
3. Server: which loaders run — reactivity key matching
The most important decision actionSingleFlight makes is which matched loaders to actually re-run. It uses reactivity keys as the coordination signal:
- Each loader, when it runs, reads atoms/queries that are registered with reactivity keys. These reads are captured automatically.
- The mutation, when it runs (or via
reactivityKeysoptions onAtom.action), emits invalidation keys. runMatchedLoadersfilters candidates: a loader only runs if its captured keys intersect with the mutation's invalidated keys.
Mutation invalidates: ["user:123", "users:list"]
UserRoute loader depends on: ["user:123"] ✓ runs (intersection)
UserListRoute loader depends on: ["users:list"] ✓ runs (intersection)
StatsRoute loader depends on: ["stats"] ✗ skipped (no intersection)
Fallback: if the mutation emits no invalidation keys (nothing was captured), the system falls back to running all matched loaders for the target URL. This is the safe default — it's better to over-fetch than to silently serve stale data.
You can override loader selection with the revalidate option:
Route.singleFlight(fn, {
revalidate: "none", // skip all loaders (use only setLoaders seeding)
revalidate: "all", // always run all matched loaders
revalidate: "reactivity" // default: reactivity-key-driven (with fallback to all)
})4. Response payload structure
// Success
{
ok: true,
payload: {
mutation: A, // the mutation result
url: string, // the target URL used for loader matching
loaders: Array<{
routeId: string, // route identifier
result: Result<any, any>, // Success | Failure | Loading
}>
}
}
// Failure
{ ok: false, error: E }5. Client: cache seeding, no second request
Route.hydrateSingleFlightPayload(payload) runs on the client after the transport receives the response.
If you already have an explicit unified route tree available, prefer Route.hydrateSingleFlightPayload(payload, root) so hydration can resolve loaders from the route tree directly.
For each entry in payload.loaders:
- Finds the registered route by
routeId. - Extracts URL params from the target URL (using the route's pattern).
- Writes the
Resultdirectly into the loader cache:setLoaderCacheEntry(routeId, params, result).
Components that are already mounted read from this cache synchronously on next render — no loading state, no flash.
hydrateSingleFlightPayload is called automatically by Route.invokeSingleFlight(...). You can call it manually if you're managing the transport yourself, or pass { hydrate: false } to invokeSingleFlight to skip it.
When you already have an explicit route tree, Route.actionSingleFlight(...) and Route.invokeSingleFlight(...) can also be given that route tree through their app option so loader selection and hydration stay tree-first instead of relying on registry lookup.
When a loader runs on the server, it's the default path — it re-fetches data fresh. But if the mutation result already contains the data the loader would return, you can short-circuit by seeding the cache directly and skipping the loader entirely. These are the three seeding APIs, ordered from most to least automatic:
Route.seedLoader(route, select?) — use when the mutation result is (or closely matches) the loader payload. No setLoaders boilerplate:
// mutation result IS the user object
setLoaders: Route.seedLoader(UserRoute)
// mutation result needs a projection
setLoaders: Route.seedLoader(UserRoute, (result) => result.user)Route.setLoaderData(route, data) — explicitly wrap data in a Success result and seed it:
setLoaders: (result) => [
Route.setLoaderData(UserRoute, result.user),
Route.setLoaderData(PermissionsRoute, result.permissions),
]Route.setLoaderResult(route, result) — provide the full Result value. Use when you want to seed a Failure or Loading state explicitly:
setLoaders: (result) =>
result.deleted
? [Route.setLoaderResult(UserRoute, Result.failure(new NotFoundError()))]
: [Route.setLoaderData(UserRoute, result.user)]Route.seedLoaderResult(route, fn) — project the mutation result to a Result (convenience form of setLoaderResult):
setLoaders: Route.seedLoaderResult(UserRoute, (result) =>
result.ok ? Result.success(result.data) : Result.failure(result.error)
)Three server APIs at increasing levels of abstraction:
| API | Combines | Use when |
|---|---|---|
Route.singleFlight(fn, opts) |
actionSingleFlight + createSingleFlightHandler |
Always — the recommended path |
Route.actionSingleFlight(fn, opts) |
mutation runner + reactivity capture + loader selection | You need the runner separate from the HTTP adapter |
Route.createSingleFlightHandler(run, opts) |
HTTP request/response wrapping | You have a custom runner and need to adapt it to the transport protocol |
Route.mutationSingleFlight is the variant for defineMutation-style mutations (same semantics, different input shape).
The transport is the piece that moves the request envelope from client to server and back. It's deliberately separated from the mutation logic — the same Route.singleFlight server handler works regardless of whether the client uses HTTP, an RPC channel, or a direct in-process call.
Route.SingleFlightTransportTag — the service tag. When present in the mount() layer, it's automatically wired into all Atom.action handles that have singleFlight options.
Route.FetchSingleFlightTransport(options?) — the built-in HTTP transport layer:
// Simple: all single-flight requests go to the same endpoint
Route.FetchSingleFlightTransport({ endpoint: "/_sf" })
// Per-action routing via the request name
Route.FetchSingleFlightTransport({
endpoint: (request) => request.name ? `/_sf/${request.name}` : "/_sf",
})Custom transport — implement the service interface to use any transport:
const MySingleFlightLayer = Layer.succeed(Route.SingleFlightTransportTag, {
execute: (request, options) =>
myRpcChannel.call(request.name ?? "mutation", request).pipe(
Effect.map((response) => ({ ok: true, payload: response })),
Effect.catchAll((err) => Effect.succeed({ ok: false, error: err })),
),
});The transport interface only knows about request/response envelopes. It doesn't know about routes, loaders, or reactivity. That separation makes it easy to test, mock, or swap.
The router runtime does not automatically revalidate loaders after a mutation. Single-flight is the mechanism — the payload from the server is the revalidation. After hydrateSingleFlightPayload seeds the loader cache, components that depend on those loaders re-render from the cache.
For mutations that don't use single-flight, you call router.revalidate() explicitly to re-run all matched loaders for the current URL.
// Single-flight: revalidation is implicit — payload seeds the cache
const saved = await saveUser.run(input);
// Regular mutation: must revalidate manually
await saveUserRegular.run(input);
router.revalidate();| Scenario | What to use |
|---|---|
| Mutation result IS the loader data | seedLoader(Route) |
| Mutation returns partial data matching loader shape | seedLoader(Route, select) |
| Multiple loaders to seed from one result | setLoaders: (r) => [setLoaderData(A, r.a), setLoaderData(B, r.b)] |
| Mutation may produce a failure that should be shown as route error | setLoaderResult(Route, Result.failure(...)) |
| Skip all loaders, seed only | revalidate: "none" + setLoaders |
| Always revalidate all matched loaders | revalidate: "all" |
| Let reactivity decide (default) | omit revalidate |
| Transport via HTTP fetch | Route.FetchSingleFlightTransport(...) in layer |
| Transport via custom RPC | Custom Layer.succeed(Route.SingleFlightTransportTag, ...) |
| Force single-flight (fail if no transport) | singleFlight: { mode: "force", ... } |
Full guides: docs/SINGLE_FLIGHT.md, docs/SINGLE_FLIGHT_COMPARISON.md, docs/SINGLE_FLIGHT_TRANSPORT.md
Async rendering contract:
- Prefer
Route.loaderResult(returnsResultunion) withAsync,Loading,Errored,MatchTagrather than route-specific loading components. This keeps the async control-flow consistent with the rest of the library.
Examples: examples/router-basic/, examples/router-typed-links/, examples/router-single-flight/, examples/router-single-flight-fetch/
Server-side route handlers with typed request decoding, schema-based params/form/body, and structured response encoding.
Route definition:
ServerRoute.action,ServerRoute.document,ServerRoute.json,ServerRoute.resource,ServerRoute.methodServerRoute.path,ServerRoute.params,ServerRoute.query,ServerRoute.headers,ServerRoute.cookiesServerRoute.form,ServerRoute.body,ServerRoute.responseServerRoute.handle(...)— handler input shape is inferred from accumulated route metadata (params/form/body/query/headers/cookies all flow into the handler type)ServerRoute.define— finalize a server route definition
Document rendering:
ServerRoute.documentRenderer,ServerRoute.generatedPathServerRoute.runDocument(...)— run a document route within a runtimeServerRoute.document(app)accepts unified route roots directly
Graph helpers:
ServerRoute.nodes(...),ServerRoute.validate(...)— validates overlapping document patterns and invalid decode wiringServerRoute.matches(...),ServerRoute.find(...)ServerRoute.byKey(...),ServerRoute.identity(...)— observability helpers
Execution:
ServerRoute.execute(route, request)— Schema-based params/form/body decoding + basic response encodingServerRoute.executeWithServices(...),ServerRoute.executeFromServices(...)— service-native variantsServerRoute.dispatch(routes, request, { layer? })— full route dispatch with loader payload outputServerRoute.dispatchWithRuntime(runtime, request, ...)— runtime-backed dispatchServerRoute.toResponse(...)— convert dispatch results to a generic response shape (status,headers,body/html, redirect, notFound)
Control flow:
ServerRoute.redirect(...),ServerRoute.notFound()Route.ServerResponseTag— shape responses from inside handlers
Structured metadata:
ServerRouteMeta<...>— carries typed metadata aligned with runtime fields; keeps helper typing consistent
The runtime that ties history, navigation state, loaders, and server dispatch together.
RouterRuntime.create(...),RouterRuntime.createMemoryHistory(...)- Runtime methods:
initialize(),snapshot(),subscribe(),navigate(),navigateApp(),submit(),fetch(),revalidate() - Cancellation:
runtime.cancel(...),NavigationTag.cancel(...) - Service tags:
RouterRuntime.HistoryTag,RouterRuntime.NavigationTag,RouterRuntime.RouterRuntimeTag,RouterRuntime.toLayer(...)
Snapshot fields:
- Matched loader results during init/navigation/revalidation
lastActionOutcome,lastFetchOutcome,lastDocumentResult,lastDispatchResult- Unified
phase-based task objects:navigation,revalidation,requestState,dispatchState, fetcherstate RouterRuntimeOutcome,RouterTaskState— normalized outcome shapes for actions/fetches/documents/dispatchesinFlight— lightweight in-flight task ids for navigation/submit/request/dispatch/revalidate/fetchmatchedServerRoute— server-route observability/debugging
Execution model:
submit(...)/fetch(...)can execute typedServerRoutehandlers directly when passed a server route node- Repeated navigation/fetch work enters
cancelledstate before the next task begins - In-flight task registry guards against stale superseded writes
- SSR bridge:
Route.renderRequest(app, { request, layer? }),Route.ServerRequestTag,Route.ServerResponseTag Route.renderRequestWithRuntime(runtime, request, ...)— runtime-backed render- Server convenience:
Route.serverRequest,Route.serverUrl,Route.setStatus(...),Route.setHeader(...),Route.appendHeader(...),Route.serverRedirect(...),Route.serverNotFound()
Library-owned reactivity service for key-based invalidation and subscription. This sits above the signal graph — it's a semantic layer that lets async operations declare what data they touch so invalidation can be driven by intent rather than atom identity.
A query that reads users:list can be invalidated by any mutation that also declares users:list as a reactivity key — even if they share no atom reference. This makes cache invalidation composable across module boundaries.
Reactivity.Tag— service tag for reactivity providerReactivity.live— default provider with microtask batching and auto-flushReactivity.test— testing provider with manual flush control andlastInvalidatedtrackingReactivity.tracked(effect, options?)— execute an Effect while tracking which reactivity keys it reads; accumulates accessed keys internally.options.initial— initial set of tracked keysReactivity.invalidating(effect, keys)— execute an Effect that invalidates specified keys on completion
Atom helpers:
Atom.invalidateReactivity(keys)— invalidate reactivity keysAtom.trackReactivity(keys)— track which keys are accessed during a readAtom.withReactivity(keys)— register reactivity keys for an atomAtom.reactivityKeys(atom)— retrieve registered keys for an atomAtom.flushReactivity()— force-flush reactivity invalidations
The core reactive state primitive. Atoms are plain objects with read/write methods backed by the signal graph. They are callable — atom() reads the current value and registers a reactive dependency in whatever computation is running. This makes JSX natural: <div>{count()}</div> is just a function call that the reactive runtime intercepts.
Why callable reads? Compared to property access (atom.value), a call is visually explicit — you can see at a glance where reactive tracking happens. Compared to hooks, there's no ordering constraint; atoms can be read conditionally or in loops.
Atom.make(value)— create a writable atom with an initial valueAtom.make((get) => ...)— create a derived (read-only) atom;get(other)reads and tracks dependenciesAtom.value(value)— explicit writable constructor, including function-valued atoms. Use this when you want to store a function as data rather than treat it as a derived getter.Atom.derived((get) => ...)— explicit derived constructor (same asAtom.make(fn)but unambiguous)Atom.readable(read, refresh?)— low-level read-only atom constructorAtom.writable(read, write, refresh?)— low-level writable atom constructorAtom.family(fn)— memoized atom factory keyed by argument tuple. Same args return the same atom instance. Optional{ equals }for custom key equality. Exposesevict(...args)andclear()for cache cleanup.Atom.runtime(layer)— create an atom runtime bound to an EffectLayer:runtime.atom(effect)— async atom from an Effectruntime.atom((get) => effect)— dependency-aware async atom;get(...)/get.result(...)read other atoms inside the getter
Atom.runtime.addGlobalLayer(layer)— add a global layer applied to all newly-created runtimesAtom.runtimeEffect(layer)— Effect constructor variant of runtime creationAtom.keepAlive(atom)— compatibility helper; identity in this packageatom.pipe(...)— pipeable atom transformations (effect-style composition)
Async policies (pipeable):
Atom.withOptimistic(atom)/Atom.withOptimistic()— optimistic overlays withsetOptimistic,clearOptimistic,isOptimisticPending,withEffect(...)Atom.withRetry(atom, schedule)/Atom.withRetry(schedule)— retry policy for async result atomsAtom.withPolling(atom, schedule)/Atom.withPolling(schedule)— polling policyAtom.withStaleTime(atom, duration)/Atom.withStaleTime(duration)— auto-refresh after stale duration
Actions:
Atom.action(effect, options?)/Atom.action(runtime, effect, options?)— create a linear action handle from an Effect function. Actions serialize concurrent calls (later calls supersede earlier ones). Options:name,reactivityKeys,onSuccess,onError,onTransition.- Handle shape: callable +
run(input)+runEffect(input)+effect(input)+result()+pending() runEffect(input)preserves success output typeAfor Effect compositionreactivityKeysinvalidates the declared keys after a successful run
- Handle shape: callable +
Other constructors:
Atom.effect(fn)— standalone async Effect atom (no runtime required; for simple async without services)Atom.pull(stream, options?)— pull-based stream pagination; callset(void 0)to pull next chunkAtom.projection(derive, initial, options?)— mutable derived projection; mutate draft or return next value with keyed reconciliationAtom.projectionAsync(derive, initial, options?)— async projection returningResult<T, E>; usesoptions.runtimeor ambient mount runtimeAtom.searchParam(name, codec?)— atom bound to a URL search param (browser only)Atom.kvs({ key, defaultValue, ... })— atom backed by key-value storage (localStorage by default)Atom.Stream.*— advanced stream assembly helpers (see Stream Integration below)
const count = Atom.make(0);
const doubled = Atom.make((get) => get(count) * 2);
// Store a function as data — use Atom.value, not Atom.make
const callback = Atom.value((n: number) => n + 1);
// Family: same "alice" argument always returns the same atom
const todoById = Atom.family((id: string) => Atom.make({ id, done: false }));
// Runtime: bind Effect layer once, derive many atoms safely
const runtime = Atom.runtime(MyLayer);
const userAtom = runtime.atom(
Effect.service(UserApi).pipe(Effect.flatMap((api) => api.me()))
);
const increment = runtime.action((n: number) => Effect.sync(() => console.log(n)));
// Projection: mutable computed view with draft mutation
const selectedMap = Atom.projection((draft: Record<string, boolean>) => {
draft["a"] = true;
}, {});Atom.map(atom, fn)/Atom.map(fn)— derive a new atom by transforming the value; data-first and data-last formsAtom.withFallback(atom, fallback)/Atom.withFallback(fallback)— replacenull/undefinedwith a fallback
const label = Atom.map(count, (n) => `Count: ${n}`);
const safe = Atom.withFallback(nameAtom, "anonymous");Writable atoms are callable and expose sync instance methods for component ergonomics:
atom()— read current value reactivelyatom.set(value)— sync writeatom.update(fn)— sync update from previous valueatom.modify(fn)— sync read-modify-write, returns a computed value
Effect helpers (all support data-first Atom.set(atom, value) and data-last Atom.set(value) forms):
Atom.get(atom)→Effect<A>— read atom valueAtom.result(atom)→Effect<A, E | BridgeError>— unwrapResult/FetchResultatoms into typed Effects. Fails withResultLoadingErrorif still loading,ResultDefectErrorif defected.Atom.set(atom, value)→Effect<void>— write atom valueAtom.update(atom, fn)→Effect<void>— update from previous valueAtom.modify(atom, fn)→Effect<A>— read-modify-write, returning a computed valueAtom.refresh(atom)→Effect<void>— force-invalidate atom and its dependents- Aliases for Effect-first naming:
Atom.getEffect,Atom.resultEffect,Atom.setEffect,Atom.updateEffect,Atom.modifyEffect
count(); // 0 — reactive read
count.update((n) => n + 1); // sync write
const prev = count.modify((n) => [n, n + 1]); // read-modify-write
// Effect helpers for pipelines
Effect.runSync(Atom.get(count));Atom.subscribe(atom, listener, options?)— subscribe to value changes; returns unsubscribe. Calls listener immediately by default ({ immediate: false }to skip).Atom.flush()— flush queued reactive invalidations immediately. Notification mode is always microtask.
Atom.fromStream(stream, initialValue, runtime?)— atom whose value updates from an Effect Stream; starts a fiber on first readAtom.fromQueue(queue, initialValue)— shorthand:fromStream(Stream.fromQueue(queue), initial)Atom.fromSchedule(schedule, initialValue, runtime?)— atom from an EffectScheduleviaStream.fromScheduleAtom.Stream.textInput(stream, options?)— stream recipe for UI text input normalization (trim,minLength)Atom.Stream.searchInput(stream, options?)— search-box recipe (trim/minLength+ optionallowercase+distinct)Atom.Stream.emptyState<T>()— empty stream state for out-of-order assemblyAtom.Stream.applyChunk<T>(state, chunk)— apply a chunk to stream state, handling out-of-order updatesAtom.Stream.hydrateState<T>(value)— stream state initialized with a hydrated server value
const prices = Atom.fromStream(priceStream, 0);
const events = Atom.fromQueue(eventQueue, null);
// Out-of-order stream assembly (SSR + client patches)
const initialState = Atom.Stream.hydrateState(serverInitialList);
const updatedState = Atom.Stream.applyChunk(initialState, newItem);Atom.isAtom(u)—trueifuis anAtom<any>Atom.isWritable(atom)—trueif the atom is aWritable<R, W>
Atom.Atom<A>— read-only atomAtom.Writable<R, W>— readable asR, writable asWAtom.Context— callable read context withget,refresh,set,result,addFinalizerAtom.WriteContext<A>— write context withget,set,refreshSelf,setSelf,result,addFinalizerAtom.AtomRuntime<R, E>— runtime wrapper withmanaged,atom(...),action(...),dispose()Atom.ProjectionOptions<T>/Atom.ProjectionAsyncOptions<T, R>— projection configuration
Schema-validated form fields backed by atoms. The core idea is that a form field has two representations: the raw input (what the user typed, possibly invalid) and the parsed value (the typed output if validation passes). ValidatedAtom holds both as atoms, so you can bind the input to a UI element and read the parsed value only when you need it.
AtomSchema.struct lets you compose multiple fields into a typed form model with unified isValid, touch, and reset behavior — without writing per-field boilerplate.
AtomSchema.make(schema, inputAtom, options?)— wrap an existing writable atom with validation.options.initialis the baseline fordirtycomparison andreset().AtomSchema.makeInitial(schema, initial)— create a standalone validated atom with an initial valueAtomSchema.validated(schema, options?)— pipeable schema wrapper for writable atomsAtomSchema.parsed(schema, options?)— alias ofvalidatedfor parse-oriented namingAtomSchema.struct(fields)— compose many validated fields (including nested structs) into one typed form model. Struct API:input,value,error,touched,dirty,isValid,touch(),reset().AtomSchema.path(root, ...segments)— writable atom focused on a nested object pathAtomSchema.validateEffect(fieldOrStruct)— validate and read typed form values as an EffectAtomSchema.HtmlInput— built-in form codecs:number(Schema.NumberFromString)date(Schema.Date)optionalString— schema +input(value)for empty-string mappingoptionalNumber— schema +input(value)for empty-string mapping
const field = AtomSchema.makeInitial(Schema.Int, 25);
field.isValid(); // true
field.input.set(1.5);
field.isValid(); // false — 1.5 is not an Int
field.reset(); // back to 25| Property | Type | Description |
|---|---|---|
input |
Writable<I, I> |
Raw input atom (writes mark field as touched) |
result |
Atom<Exit<A, SchemaError>> |
Parse result |
error |
Atom<Option<SchemaError>> |
Validation error or None |
value |
Atom<Option<A>> |
Parsed value or None |
isValid |
Atom<boolean> |
true when input passes validation |
touched |
Atom<boolean> |
true after first write |
dirty |
Atom<boolean> |
true when input differs from initial |
reset() |
() => void |
Restore initial value, clear touched |
AtomSchema.ValidatedAtom<A, I>AtomSchema.SchemaError
Structured debug logging for atom reads and writes using Effect's Logger. Wraps atoms transparently — the rest of your code doesn't know logging is in place.
AtomLogger.traced(atom, label)— wrap a read-only atom to log reads viaEffect.logDebugwith{ atom, op, value }annotationsAtomLogger.tracedWritable(atom, label)— wrap a writable atom to log both reads and writesAtomLogger.logGet(atom, label?)→Effect<A>— read atom as an Effect with debug loggingAtomLogger.logSet(atom, value, label?)→Effect<void>— write atom as an Effect with debug loggingAtomLogger.snapshot(atoms)→Effect<Record<string, unknown>>— read all labeled atoms and return a snapshot (useful in tests and bug reports)
const traced = AtomLogger.tracedWritable(count, "count");
const snap = Effect.runSync(AtomLogger.snapshot([["count", count], ["name", name]]));Import:
effect-atom-jsx/Registry
A centralized read/write/subscribe context for atoms. Useful when you need to manage atom state outside of a reactive computation — in tests, in server environments, or when mounting multiple isolated trees.
In most cases, use mount() for automatic registry management. The registry API is for advanced scenarios where you need explicit control over atom lifetimes.
Registry.make()— create a new registry instanceRegistry.useRegistry()— get ambient owner-scoped registry (stable per owner, auto-disposed on cleanup). Outside any owner, returns a shared detached registry.
| Method | Signature | Description |
|---|---|---|
get(atom) |
<A>(atom: Atom<A>) => A |
Read current value |
set(atom, value) |
<R,W>(atom: Writable<R,W>, value: W) => void |
Write a value |
update(atom, fn) |
<R>(atom: Writable<R,R>, fn: (v: R) => R) => void |
Update from previous |
modify(atom, fn) |
<R,W,A>(atom: Writable<R,W>, fn: (v: R) => [A, W]) => A |
Read-modify-write |
subscribe(atom, fn) |
<A>(atom: Atom<A>, fn: (v: A) => void) => () => void |
Subscribe to changes |
mount(atom) |
<A>(atom: Atom<A>) => () => void |
Keep atom alive (run effects) |
refresh(atom) |
<A>(atom: Atom<A>) => void |
Force-invalidate |
reset() |
() => void |
Dispose mounted owners and clear registry |
dispose() |
() => void |
Clean up all subscriptions |
Registry.Registry
A three-state result type (Initial, Success, Failure) for advanced compatibility and explicit waiting semantics.
Note: Core async APIs now use
Resultfromeffect-ts.FetchResultis an advanced compatibility model with conversion helpers. For new code, preferResult.
FetchResult.initial(waiting?)— create an initial result;waiting: truemeans a fetch is in progressFetchResult.success(value, options?)— create a success result; options:{ waiting?, timestamp? }FetchResult.failure(error, options?)— create a failure result; options:{ previousSuccess? }
FetchResult.isInitial, isSuccess, isFailure, isWaiting, isNotInitial, isResult
FetchResult.map(result, fn)— map over success valueFetchResult.flatMap(result, fn)— chain resultsFetchResult.match(result, { initial, success, failure })— pattern match all statesFetchResult.all(results)— combine multiple results (all must succeed)FetchResult.builder(result)— fluent builder with.onInitial(...),.onFailure(...),.onSuccess(...),.render()
FetchResult.value(result)— extract success value orundefinedFetchResult.getOrElse(result, fallback)— success value or fallbackFetchResult.getOrThrow(result)— success value or throw
FetchResult.fromResult(result)— convert coreResulttoFetchResultFetchResult.toResult(result)— convertFetchResultto coreResultFetchResult.fromExit(exit)— convert EffectExittoFetchResultFetchResult.fromExitWithPrevious(exit, previous)— convert Exit, preserving previous success on failureFetchResult.waiting(result)— setwaiting: trueon an existing resultFetchResult.waitingFrom(result)— create a waiting version, preserving success value
FetchResult.Result<A, E>—Initial | Success<A> | Failure<E>
Per-property reactive access to objects and arrays. Use AtomRef when you have a shared object where different parts of the UI need to subscribe to different properties — it avoids splitting the object into many independent atoms while still enabling fine-grained updates.
AtomRef is a ref abstraction, not an Atom type directly. Use AtomRef.toAtom(ref) to bridge into atom combinators (Atom.map, etc.).
AtomRef.make(initial)— create a ref for an object; returnsAtomRef<A>AtomRef.collection(items)— create a reactive array; returnsCollection<A>AtomRef.toAtom(ref)— convert anAtomRef<A>toWritable<A, A>for atom-graph interop
| Method | Description |
|---|---|
ref() |
Read current value (callable — reactive tracking) |
get() |
Read current value (method form) |
prop(key) |
Get a reactive ref for a single property |
set(value) |
Replace the entire object |
update(fn) |
Update via a function |
modify(fn) |
Read-modify-write and return a computed value |
subscribe(fn) |
Subscribe to changes |
value |
Current snapshot (non-reactive) |
| Method | Description |
|---|---|
push(item) |
Append an item |
insertAt(index, item) |
Insert at position |
remove(ref) |
Remove by item ref identity |
toArray() |
Get current items array |
AtomRef.AtomRef<A>,AtomRef.ReadonlyRef<A>,AtomRef.Collection<A>
SSR state transfer — serialize atom values on the server and restore them on the client. The pattern prevents a flash of loading state: the server serializes atoms as part of the HTML response, and the client restores them before first render.
Hydration.dehydrate(registry, entries)— snapshot atom values to a serializable array.entries:Iterable<[key: string, atom: Atom<any>]>. ReturnsDehydratedAtomValue[].Hydration.hydrate(registry, state, resolvers, options?)— restore atom values from a dehydrated snapshot.resolvers:Record<string, Writable<any, any>>mapping keys to atoms.options.validateemits warnings for unknown server keys and missing resolver keys.options.onUnknownKey/options.onMissingKey: custom validation callbacks.Hydration.toValues(state)— filter dehydrated state to typed value entriesHydration.hydrateEffect(registry, state, resolvers, options?)— Effect constructor variant with optional strict typed failures
// Server
const state = Hydration.dehydrate(registry, [["count", countAtom]]);
// → embed `state` as JSON in HTML
// Client
Hydration.hydrate(registry, state, { count: countAtom });
// → countAtom is now pre-populated before first renderHydration.DehydratedAtom,Hydration.DehydratedAtomValue,Hydration.HydrateOptions,Hydration.HydrationError
RPC client factory for flat endpoint maps. Wraps a transport function into a typed client with reactive query, mutation, and action handles.
AtomRpc.Tag()(id, { call, runtime? })— create a typed RPC client:query(tag, payload, options?)— reactive query;options.reactivityKeyssupportedmutation(tag, options?)— Effect mutation;options.reactivityKeysinvalidates declarativelyaction(tag, options?)— linear action handle with.run/.runEffect/.effect/.result/.pending; supportsreactivityKeys,onErrorrefresh(tag, payload)— force-refresh a query
AtomRpc.AtomRpcClient<Defs, R>
HTTP API client factory for grouped endpoints. Same ergonomics as AtomRpc but organized by group/endpoint rather than flat tags.
AtomHttpApi.Tag()(id, { call, runtime? })— create a typed HTTP API client:query(group, endpoint, request, options?)— reactive query;options.reactivityKeyssupportedmutation(group, endpoint, options?)— Effect mutation;options.reactivityKeysinvalidates declarativelyaction(group, endpoint, options?)— linear action handle with.run/.runEffect/.effect/.result/.pending; supportsreactivityKeys,onErrorrefresh(group, endpoint, request)— force-refresh a query
AtomHttpApi.AtomHttpApiClient<Defs, R>
For practical usage patterns and edge cases, see docs/ACTION_EFFECT_USE_RESOURCE.md.
Resultand scoped constructors are also available fromeffect-atom-jsx/advanced.
atomEffect(fn, runtime?)— low-level reactive async computation. Tracks signal dependencies, interrupts previous fiber on re-run. Useful when you need direct control over the reactive graph.defineQuery(fn, options?)— ergonomic keyed query bundle. Returns{ key, result, pending, latest, effect, invalidate, refresh }. The preferred API when you need caching, invalidation, or observability.options.onTransition— emits{ phase: start|success|failure|defect, name? }for observabilityoptions.retrySchedule— retries typed failures with an EffectSchedulebefore settling toFailureoptions.pollSchedule— invalidates the query key on a schedule for polling-style refreshoptions.observe— emits metrics events{ kind, phase, name?, startedAt, finishedAt?, durationMs? }
scopedQueryEffect(scope, fn, options?)— Effect constructor variant for scope-bound query accessorsImport from:
effect-atom-jsx/advancedcreateQueryKey<A>(name?)— create typed invalidation keys for queriesinvalidate(key)— invalidate one or many query keysisPending(result)—Accessor<boolean>;trueonly duringRefreshing(not initialLoading). Useful for showing a subtle "revalidating" spinner without hiding existing data.latest(result)—Accessor<A | undefined>with the last successful value. Useful for keeping old data visible during a refresh.query.effect()— convert query state toEffect<A, E | BridgeError>for typed composition in generator flows
Three async APIs serve different use cases. The right choice depends on whether you need services, invalidation, or just a reactive async value:
| API | Returns | Runtime | Dependencies | Use Case |
|---|---|---|---|---|
Atom.effect(fn) |
Atom<Result> |
No | None | Simple async without services |
Atom.query(fn, runtime?) |
Atom<Result> |
Optional | Effect services | Service-based query with DI |
atomEffect(fn, runtime?) |
Signal<Result> |
Optional | Signal reads | Low-level reactive effects in Computation contexts |
defineQuery(fn, options?) |
QueryRef |
Optional | All of the above | Keyed queries with invalidation, observability, polling |
// No services needed → Atom.effect
const posts = Atom.effect(() => fetch('/posts').then(r => r.json()));
// posts() → Result<PostList, FetchError>
// Needs an injected service → Atom.query or defineQuery
const user = Atom.query(() =>
Effect.service(UserApi).pipe(
Effect.flatMap((api) => api.getUser("123"))
)
);
// user() → Result<User, ApiError>
// Needs invalidation, polling, or observability → defineQuery
const data = defineQuery(() => fetch('/data'), {
name: "fetchData",
onTransition: ({ phase }) => console.log("Phase:", phase),
pollSchedule: Schedule.spaced("30 seconds"),
});
// data.result() → Result
// data.invalidate() → trigger refreshuseService(tag)— synchronously access a service from the ambient runtime. Throws if called outside amount(..., layer)tree; includes the missing service key when runtime exists but service is not provided.useServices({ ...tags })— resolve multiple services at once with inferred return typesmount(fn, container, layer)— bootstrap aManagedRuntimefrom aLayerand render. AlluseServicecalls inside the tree resolve from this runtime.createMount(layer)— create a mount function pre-bound to a layerlayerContext(layer, fn, runtime?)— run a function with a Layer-provided contextImport from:
effect-atom-jsx/advanced- Component and mount lifetimes are scope-backed: disposing a parent root interrupts descendant Effect fibers transitively
scopedRootEffect(scope, fn)— Effect constructor variant for creating a reactive root tied to an Effect ScopeImport from:
effect-atom-jsx/advanced
createOptimistic(source)— create an optimistic overlay withget,set,clear,isPending.sourcecan be any callable read (Accessor<T>or callable atom). The overlay is temporary state: UI reads from it while the real mutation is in-flight; clear it on success or rollback on failure.defineMutation(fn, options?)— create an Effect-powered mutation action withoptimistic,rollback,onSuccess,onFailure,refreshhooks. Returns{ run, effect, result, pending }. Supportsinvalidatesfor query key invalidation.effect(input)returnsEffect<void, E | BridgeError | MutationSupersededError>options.onTransitionemits{ phase: start|success|failure|defect }options.observeemits metrics events
scopedMutationEffect(scope, fn, options?)— Effect constructor variant for scope-bound mutation handlesImport from:
effect-atom-jsx/advanced
Result has five states because UI needs to distinguish cases that are often collapsed together:
| Variant | Description | Why separate? |
|---|---|---|
Loading |
Initial load, no value yet | First load needs a full skeleton/spinner, not just a subtle indicator |
Refreshing<A, E> |
Revalidating with previous settled value | Can show stale data + subtle indicator instead of hiding content |
Success<A> |
Settled with a value | Normal case |
Failure<E> |
Settled with a typed error | Expected error you can handle specifically |
Defect |
Unexpected defect or interrupt | Programming error or unhandled exception — usually a generic error boundary |
Failure vs Defect: Failure carries a typed E — you know what went wrong and can handle it specifically (e.g., show a "not found" message). Defect is the untyped escape hatch for things that shouldn't happen — bugs, unexpected exceptions, fiber interruptions. Separating them means your error UI can be typed and specific, not a generic catch-all.
Refreshing vs Loading: Refreshing carries the previous A and E values. This lets you show the last known data while a refresh is in progress, rather than replacing content with a spinner. isPending(result) is true only during Refreshing, not Loading.
Constructors/helpers: Result.loading, refreshing, success, failure, defect, settled, fromExit, toExit, toOption, rawCause
Guards: Result.isLoading, isRefreshing, isSuccess, isFailure, isDefect
These components pattern-match Result or conditional values and render the appropriate slot. They are the reactive equivalent of switch statements over async state.
Async({ result, loading?, refreshing?, success, error?, defect? })— render slots based onResultstate.refreshing?is optional; falls back tosuccessslot if not provided (showing stale data during refresh).Loading({ when, fallback?, children })— show children while loadingErrored({ result, children })— show children on errorTypedBoundary({ result, catch, children })— show children only when error matches a type guard orSchemaShow({ when, fallback?, children })— conditional renderingFor({ each, children })— list rendering with keyingSwitch/Match({ when, children })— multi-case conditionalMatchTag({ value, cases, fallback? })— type-safe_tagpattern matching. Works with any Effect-style tagged union.Optional({ when, fallback?, children })— render when truthyMatchOption({ value, some, none? })— match EffectOptionDynamic({ component, ...props })— dynamic component selection at runtimeWithLayer({ layer, runtime?, fallback?, children })— provide a Layer boundary to a subtreeFrame({ children })/createFrame(initial?)— animation frame loop
Result<A, E>,Loading,Refreshing<A, E>,Success<A>,Failure<E>,DefectBridgeError(ResultLoadingError | ResultDefectError),MutationSupersededErrorRuntimeLike<R, E>,OptimisticRef<T>,MutationEffectHandle<A, E>,MutationEffectOptions<A, E, R>
Import from:
effect-atom-jsx/internals
Solid.js-compatible reactive primitives. These are the foundation that Atom is built on. You rarely need these directly — the Atom API is the intended surface. Use these only for advanced scenarios requiring direct signal graph access, or for interop with code written in Solid.js style.
createSignal<T>(initial, options?)→[Accessor<T>, Setter<T>]createEffect(fn)— run side effect when dependencies changecreateMemo(fn, options?)→Accessor<T>— cached derived valuecreateRoot(fn)— create a new reactive ownership scopecreateContext(defaultValue)/useContext(ctx)— dependency injectiononCleanup(fn)— register cleanup when owner disposesonMount(fn)— run after component mountsuntrack(fn)/sample(fn)— read without tracking dependenciesbatch(fn)— batch multiple writes into one notification passflush()— flush queued updates immediatelymergeProps(...sources)/splitProps(props, keys)— props utilitiesgetOwner()/runWithOwner(owner, fn)— ownership utilities
Why ownership matters: Every reactive computation runs inside an owner. When an owner disposes, all effects and cleanups registered under it run automatically. This prevents memory leaks and dangling subscriptions. The Atom API manages ownership transparently through mount() and Component.make; the raw createRoot is for cases where you need explicit scope control.
Accessor<T>,Setter<T>,SignalOptions<T>,Context<T>
Functions called by babel-plugin-jsx-dom-expressions compiled JSX output. You don't call these directly — the Babel plugin generates calls to them. They are documented here for framework authors and for understanding the compiled output.
template(html)— create reusable DOM template from HTML stringinsert(parent, accessor, marker?, current?)— insert reactive childrencreateComponent(Comp, props)— instantiate a component in a new reactive rootspread(node, accessor, isSVG?, skipChildren?)— reactive prop spreadingattr(node, name, value)/prop(node, name, value)— set attributes/propertiesclassList(node, value, prev?)— reactive class togglingstyle(node, value, prev?)— reactive inline stylesdelegateEvents(events)— set up global event delegationrender(fn, container)— mount a component tree; returns dispose functionrenderWithHMR(fn, container, hot?, key?)— mount with Vite HMR self-accept + previous dispose handlingwithViteHMR(dispose, hot?, key?)— attach any disposer to Vite HMR lifecycle
isServer—truewhenwindow/documentare unavailablerenderToString(fn)— render component tree to HTML string using virtual DOMhydrateRoot(fn, container)— attach reactivity to server-rendered DOM; returns dispose functionisHydrating()—trueduring hydration passgetNextHydrateNode()— advance hydration walker (for custom component hydration)getRequestEvent()/setRequestEvent(event)— SSR request context
For JSX runtime transforms, use the package entry: effect-atom-jsx/runtime.
Testing utilities for reactive code without requiring DOM or jsdom.
TestHarness combines an Effect runtime with a reactive ownership scope into one object. This lets you run atoms, actions, and queries inside tests as if they were mounted inside a real component tree — without any DOM setup.
TestHarness<R>— test environment:runtime— the underlying EffectManagedRuntime<R>owner— the reactiveOwnerscopecleanup()— dispose runtime and reactive scoperun<A>(fn: () => A)— execute function in test context (atoms resolve, effects fire)
import { TestHarness } from "effect-atom-jsx/testing";
const harness = new TestHarness(MyLayer);
try {
harness.run(() => {
const count = Atom.make(0);
count.set(5);
expect(count()).toBe(5);
});
} finally {
harness.cleanup();
}For full testing patterns, see docs/TESTING.md.
Babel JSX plugin integration. This module is imported by babel-plugin-jsx-dom-expressions during compilation. You configure it once and never import it directly.
Configure Babel:
{
"plugins": [
["babel-plugin-jsx-dom-expressions", {
"moduleName": "effect-atom-jsx/runtime",
"generate": "dom"
}]
]
}For users: Handled automatically by the Babel plugin.
For framework authors: See babel-plugin-jsx-dom-expressions documentation for custom runtime implementation requirements.
Low-level Solid.js-compatible reactive primitives. Also re-exported from effect-atom-jsx/advanced.
When to use: Only in advanced scenarios where you need direct access to the reactive graph — custom integrations, Solid.js interop, or building your own abstractions on top of the signal system.
For most applications: Use the Atom API instead.
Exports: createSignal, createEffect, createMemo, createRoot, createContext, useContext, onCleanup, onMount, untrack, sample, batch, flush, mergeProps, splitProps, getOwner, runWithOwner.
These are 100% compatible with Solid.js signal/effect patterns.