Fine-grained reactive JSX runtime powered by Effect v4. Combines effect-atom style state management, a dom-expressions JSX runtime, and Effect v4 service integration into a single, cohesive framework.
npm i effect-atom-jsx effect@^4.0.0-beta.29Targets
effect@^4.0.0-beta.29
effect-atom-jsx = Effect v4 services + Atom/Registry state + dom-expressions JSX
- Local state via
Atom/AtomRef— reactive graph primitives (Registryavailable for advanced/manual control) - Async state via
defineQuery/atomEffect/Atom.fromResource— Effect fibers with automatic cancellation - Mutations via
defineMutation/createOptimistic— optimistic UI with rollback - Testing via
renderWithLayer/withTestLayer/mockService— DOM-free test harness - Form validation via
AtomSchema— Schema-driven reactive fields with touched/dirty tracking - SSR via
renderToString/hydrateRoot— server-side rendering with hydration - Debug via
AtomLogger— structured logging for atom reads/writes
{
"plugins": [
["babel-plugin-jsx-dom-expressions", {
"moduleName": "effect-atom-jsx",
"contextToCustomElements": true
}]
]
}Components are plain functions that run once. Reactive expressions in JSX update only the specific DOM nodes that depend on them.
import { Atom, render } from "effect-atom-jsx";
function Counter() {
const count = Atom.make(0);
const doubled = Atom.make((get) => get(count) * 2);
return (
<div>
<p>Count: {count()} (doubled: {doubled()})</p>
<button onClick={() => count.update((c) => c + 1)}>+</button>
</div>
);
}
render(() => <Counter />, document.getElementById("root")!);
// Vite HMR helper (optional):
// const hot = (import.meta as ImportMeta & { hot?: ViteHotContext }).hot;
// renderWithHMR(() => <Counter />, document.getElementById("root")!, hot);import { Effect, Layer, ServiceMap } from "effect";
import { createMount, useService, defineQuery, Async } from "effect-atom-jsx";
const Api = ServiceMap.Service<{
readonly load: () => Effect.Effect<number>;
}>("Api");
const ApiLive = Layer.succeed(Api, {
load: () => Effect.succeed(42),
});
function App() {
const data = defineQuery(() => useService(Api).load(), { name: "app-data" });
return (
<Async
result={data.result()}
loading={() => <p>Loading...</p>}
success={(value) => <p>Loaded: {value}</p>}
/>
);
}
const mountApp = createMount(ApiLive);
mountApp(() => <App />, document.getElementById("root")!);Atom: core reactive unit; callable read (count()) plus write methods on writable atoms (set/update/modify).derived atom: read-only atom computed from other atoms (Atom.make((get) => ...)orAtom.derived(...)).Query: reactive async read (defineQuery), returns aQueryRefwithresult,pending,latest,effect,invalidate.Mutation: callback-style async write (defineMutation), returns handle withrun,effect,result,pending.Action: linear runtime-bound write (Atom.runtime(layer).action(...)), preferred service mutation path.Result: primary async state model (Loading/Refreshing/Success/Failure/Defect).Effect(capital E): typed effect program from theeffectpackage (Effect<A, E, R>).effect(...)methods (lowercase): bridge helpers that expose state handles asEffectprograms (query.effect(),mutation.effect(input),action.effect(input)).Ref(AtomRef): object/collection-focused reactive state with property-level access (ref.prop("x")) and callable reads (ref()).Optimistic: temporary overlay on top of a source value (createOptimistic) used for immediate UI before async confirmation.Store: not a separate top-level primitive in this package; useAtomReforAtom.projection(...)for object/draft-style state.
If you use Effect heavily, this is the key model:
A= success value typeE= typed error channelR= required services/context
Effect values always carry all three: Effect<A, E, R>.
import { Effect, Schedule } from "effect";
import { Atom, Async } from "effect-atom-jsx";
// Effect<User[], HttpError, Api>
const usersEffect = Effect.gen(function* () {
const api = yield* Api;
return yield* api.listUsers();
});
const apiRuntime = Atom.runtime(ApiLive);
// Runtime binding satisfies R (Api), so resulting atom is runtime-bound.
const users = apiRuntime.atom(usersEffect);
// You can annotate with public aliases when you want explicitness:
// const users: Atom.AsyncAtom<User[], HttpError> = apiRuntime.atom(usersEffect);
// users() -> Result<User[], HttpError>
// users.effect() -> Effect<User[], HttpError | BridgeError>
// Dependency-aware runtime atom composition
const profile = apiRuntime.atom((get) =>
Effect.gen(function* () {
const xs = yield* get.result(users);
return xs.length;
}),
);Atom.runtime(layer) accepts any effect whose requirements are a subset of the runtime layer output (RReq extends R).
How this appears in UI:
<Async
result={users()}
error={(e) => <ErrorView error={e} />} // e includes your typed E (e.g. HttpError)
success={(xs) => <UserList users={xs} />}
/>Writable vs read-only state:
- Writable atoms (
Atom.make(value),Atom.value(value)) exposeset/update/modify. - Derived atoms (
Atom.make((get) => ...),Atom.derived(...)) are read-only.
For most apps, start with this stack:
- Local state:
Atom.make/Atom.value/Atom.derived - Service/runtime wiring:
Atom.runtime(layer)for service-bound atoms/actions (preferred) - Ambient runtime alternative:
createMount(layer)+useService(Tag) - Async reads:
defineQuery(...) - Writes:
Atom.runtime(...).action(...)(primary) ordefineMutation(...)(callback alternative) - Optimistic UX:
createOptimistic(...) - Async UI rendering:
Async,Loading,Errored
For runtime-bound atom APIs, prefer:
Atom.runtime(layer).atom(...)for readsAtom.runtime(layer).action(...)for writes (linear Effect flow)Atom.effect(...)for standalone async atoms
Batching uses microtask mode by default. Use flush() when you need immediate deterministic commit ordering.
Everything else (scoped* constructors, explicit registries outside components, deep runtime helpers) is advanced.
Atoms are reactive values. Most component code uses callable atoms directly. Registry is for advanced/manual control.
import { Effect } from "effect";
import { Atom } from "effect-atom-jsx";
import * as Registry from "effect-atom-jsx/Registry";
const count = Atom.make(0);
const doubled = Atom.map(count, (n) => n * 2);
const callback = Atom.value((n: number) => n + 1);
// Callable atoms are the default read/write path in components
count.set(3);
console.log(doubled()); // 6
// Atom also exposes Effect-based helpers
Effect.runSync(Atom.update(count, (n) => n + 1));All Effect helpers (get, set, update, modify) support both data-first and data-last (pipeable) forms.
Registry remains available for advanced/manual control. Registry.useRegistry() returns an ambient registry scoped to the current reactive owner (component/root) and auto-disposes it on cleanup. For explicit standalone usage (tests, scripts, server handlers), use Registry.make().
useService(...) diagnostics include actionable mount/layer guidance and best-effort available-service hints when a service is missing.
Atom.make(...) disambiguation:
Atom.make(value)-> writable atomAtom.make((get) => ...)-> derived read-only atomAtom.value(value)-> explicit writable atom (including function values)Atom.derived((get) => ...)-> explicit derived atom
import { Effect, Layer, ServiceMap } from "effect";
import { Atom, Async, For, isPending, latest, Show } from "effect-atom-jsx";
const Api = ServiceMap.Service<{
readonly listUsers: () => Effect.Effect<ReadonlyArray<{ id: string; name: string }>>;
readonly addUser: (name: string) => Effect.Effect<void>;
}>("Api");
const ApiLive = Layer.succeed(Api, {
listUsers: () => Effect.succeed([{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }]),
addUser: (_name: string) => Effect.void,
});
const apiRuntime = Atom.runtime(ApiLive);
const users = Atom.withReactivity(
apiRuntime.atom(
Effect.gen(function* () {
const api = yield* Api;
return yield* api.listUsers();
}),
),
["users"],
);
const addUser = apiRuntime.action(
Effect.fn(function* (name: string) {
const api = yield* Api;
yield* api.addUser(name);
}),
{
name: "add-user",
reactivityKeys: ["users"],
onTransition: ({ phase }) => {
if (phase === "failure" || phase === "defect") {
console.warn("add-user failed");
}
},
},
);
// Typed composition path
const addUserProgram = addUser.effect("Charlie");
const usersProgram = users.effect();
function UsersView() {
const refreshing = isPending(users);
const latestUsers = latest(users);
return (
<>
<Show when={refreshing()}>
<p>Refreshing...</p>
</Show>
<Show when={latestUsers()}>
{(xs) => <p>Showing {xs().length} cached users while revalidating.</p>}
</Show>
<Async
result={users()}
loading={() => <p>Loading...</p>}
success={(xs) => (
<ul><For each={xs}>{(u) => <li>{u().name}</li>}</For></ul>
)}
/>
<button onClick={() => addUser("Charlie")}>Add</button>
</>
);
}isPending(resultAccessor) returns Accessor<boolean> and is true only during Refreshing.
latest(resultAccessor) returns Accessor<A | undefined> with the last successful value.
How this flow maps to concepts:
usersis an async atom (query-like read) whose value isResult<User[], E>.addUseris an action (write) that runs anEffectand invalidates logical reactivity keys.Asynchandles first load;isPending+latesthandle stale-while-revalidate updates.users.effect()/addUser.effect(...)are composition bridges when you need pureEffectprograms.
Use Atom.family for keyed atom factories. Entries are cached by key until explicitly evicted.
const userAtom = Atom.family((id: string) =>
apiRuntime.atom(
Effect.gen(function* () {
const api = yield* Api;
return yield* api.findUser(id);
}),
),
);
const a = userAtom("user-1");
const b = userAtom("user-2");
userAtom.evict("user-1"); // remove one cached entry
userAtom.clear(); // remove all cached entries
// In components, evict key-scoped entries on unmount when appropriate
import { onCleanup } from "effect-atom-jsx/advanced";
function UserCard(props: { id: string }) {
const user = userAtom(props.id);
onCleanup(() => userAtom.evict(props.id));
return (
<Async
result={user()}
loading={() => <div>Loading user...</div>}
success={(u) => <div>{u.name}</div>}
/>
);
}In long-running SPAs, use evict/clear to avoid unbounded family cache growth.
Atom.family also supports multiple key parts (family((a, b) => ...) with evict(a, b)).
For structural keys, pass custom equality: Atom.family(factory, { equals: (a, b) => ... }).
AtomRef provides per-property reactive access to objects and arrays.
import { AtomRef } from "effect-atom-jsx";
const todo = AtomRef.make({ title: "Write docs", done: false });
const title = todo.prop("title");
console.log(todo()); // { title: "Write docs", done: false }
console.log(title()); // "Write docs"
title.set("Ship release notes");
console.log(title()); // "Ship release notes"
// Collections for arrays
const list = AtomRef.collection([
{ id: 1, text: "Buy milk" },
{ id: 2, text: "Write tests" },
]);
list.push({ id: 3, text: "Deploy" });
console.log(list.toArray().length); // 3todo.prop("title") returns an AtomRef<string> (not an Atom directly). Primary read style is callable (title()).
For atom-graph interop (Atom.map, etc.), use AtomRef.toAtom(title).
const titleAtom = AtomRef.toAtom(title);
const upper = Atom.map(titleAtom, (s) => s.toUpperCase());
const titleQuery = defineQuery(() => Effect.succeed(titleAtom()), { name: "title" });get.result(...) expects an atom carrying Result/FetchResult; use AtomRef.toAtom(...) for value-level interop first.
Both create reactive async computations backed by Effect fibers. When tracked dependencies change, the previous fiber is interrupted and a new one starts.
import { Effect } from "effect";
import { atomEffect, defineQuery, useService } from "effect-atom-jsx";
import { Result, Async } from "effect-atom-jsx/advanced";
// atomEffect — standalone, no runtime needed
const time = atomEffect(() =>
Effect.succeed(new Date().toISOString()).pipe(Effect.delay("1 second"))
);
// defineQuery — uses ambient Layer runtime from mount()
const data = defineQuery(() => useService(Api).load(), { name: "data" });
const users = defineQuery(() => useService(Api).listUsers(), {
name: "users",
retrySchedule: Schedule.exponential("1 second").pipe(Schedule.compose(Schedule.recurs(3))),
pollSchedule: Schedule.spaced("30 seconds"),
});
// Pattern-match on the result in JSX
<Async
result={data.result()}
loading={() => <p>Loading...</p>}
error={(e) => <p>Error: {e.message}</p>}
success={(value) => <p>{value}</p>}
/>Key difference: defineQuery uses the ambient runtime injected by mount(), while atomEffect runs Effects directly (or accepts an explicit runtime parameter).
defineQuery supports Phase E scheduling/observability options:
retrySchedule: retry typed failures before settlingpollSchedule: periodic invalidation/polling via Effect scheduleonTransitionandobserve: lightweight execution hooks for tracing/metrics
For ergonomic key + invalidation wiring, pass query.key into defineMutation({ invalidates }).
Async supports all Result states:
Loading->loading()Refreshing(previous)->refreshing(previous)if provided, otherwise reuses the settled previous rendererSuccess(value)->success(value)Failure(error)->error(error)if provided, otherwisenullDefect(cause)->defect(cause)if provided, otherwisenull
If you want defects or typed failures to escalate globally, leave local handlers undefined and use boundaries at higher levels.
Use Result as the default async model. FetchResult is an advanced compatibility model.
| Type | Module | Used by | Purpose |
|---|---|---|---|
Result<A, E> |
effect-ts.ts |
Default | Unified async state (Loading / Refreshing / Success / Failure / Defect) |
FetchResult<A, E> |
Result.ts |
Advanced compat | Data-fetching state (Initial / Success / Failure) with waiting flag |
Convert between them with FetchResult.fromResult() and FetchResult.toResult().
Important: conversion is useful but not semantically identical in every state. Result carries explicit fiber-lifecycle states (Loading, Refreshing, Defect) while FetchResult models data-centric waiting semantics. Treat conversion as an interop bridge, not a one-to-one state machine equivalence.
For explicit non-suspense rendering, use FetchResult.builder(...):
const view = FetchResult.builder(FetchResult.fromResult(users()))
.onInitial(() => <Spinner />)
.onFailure((cause) => <ErrorCard cause={cause} />)
.onSuccess((data, { waiting }) => (
<>
{waiting && <RefreshIndicator />}
<For each={data}>{(u) => <li>{u().name}</li>}</For>
</>
))
.render();Result is Exit-first internally — each settled state (Success, Failure, Defect) carries a .exit field holding the canonical Effect Exit. This enables lossless round-trips and integration with Effect's error model. Combinators Result.match, .map, .flatMap, .getOrElse, and .getOrThrow are available for ergonomic pattern matching and transformation.
Prefer linear write flows with Atom.runtime(layer).action(...) when working with services.
import { Effect } from "effect";
import { Atom, createOptimistic } from "effect-atom-jsx";
const optimisticUsers = createOptimistic(users);
const addUser = apiRuntime.action(
Effect.fn(function* (name: string) {
optimisticUsers.set((prev) => [...prev, { id: "optimistic", name }]);
const api = yield* Api;
yield* api.addUser(name);
}),
{
reactivityKeys: ["users"],
onError: () => optimisticUsers.clear(),
onSuccess: () => optimisticUsers.clear(),
},
);Use defineMutation(...) when you want callback-style lifecycle hooks.
import { Effect } from "effect";
import { Atom, createOptimistic, defineMutation } from "effect-atom-jsx";
const savedCount = Atom.make(0);
const optimistic = createOptimistic(savedCount);
const save = defineMutation(
(next: number) => Effect.succeed(next).pipe(Effect.delay("250 millis")),
{
optimistic: (next) => optimistic.set(next),
rollback: () => optimistic.clear(),
onSuccess: (next) => {
optimistic.clear();
savedCount.set(next);
},
},
);
// Typed composition path
const saveProgram = save.effect(10);
save.run(10);
console.log(optimistic()); // 10 immediatelyComposition summary:
defineQuery(...).effect()returnsEffect<A, E | BridgeError>defineMutation(...).effect(input)returnsEffect<void, E | BridgeError | MutationSupersededError>Atom.runtime(...).action(...).effect(input)returnsEffect<void, E | BridgeError | MutationSupersededError>Atom.runtime(...).action(...).runEffect(input)returnsEffect<A, E | BridgeError | MutationSupersededError>(preserves action success value)Atom.result(atom)converts result-like atoms into typedEffectvalues for pipelines
BridgeError is tagged (ResultLoadingError | ResultDefectError) so composition errors stay explicit in the Effect error channel.
Wraps atoms with Effect Schema for reactive validation with form state tracking.
import { Schema, Effect, Option } from "effect";
import { Atom, AtomSchema } from "effect-atom-jsx";
const ageField = AtomSchema.makeInitial(Schema.Int, 25);
// Each field provides reactive accessors
ageField.value; // Atom<Option<number>> — parsed value
ageField.error; // Atom<Option<SchemaError>> — validation error
ageField.isValid; // Atom<boolean>
ageField.touched; // Atom<boolean> — modified since creation?
ageField.dirty; // Atom<boolean> — differs from initial?
// Write invalid input
Effect.runSync(Atom.set(ageField.input, 1.5));
Effect.runSync(Atom.get(ageField.isValid)); // false
// Reset everything
ageField.reset(); // restores initial value, clears touched
const profile = AtomSchema.struct({
age: AtomSchema.makeInitial(Schema.Int, 25),
score: AtomSchema.makeInitial(Schema.Int, 10),
});
profile.isValid();
profile.touch();
profile.input.set({ age: 30, score: 11 });
profile.values(); // Accessor<Option<{ age: number; score: number }>>
const address = AtomSchema.struct({
city: AtomSchema.makeInitial(Schema.String, ""),
zip: AtomSchema.makeInitial(Schema.Int, 12345),
});
const userForm = AtomSchema.struct({
profile,
address,
});
userForm.reset();Structured logging for atom reads and writes using Effect's Logger.
import { Effect } from "effect";
import { Atom, AtomLogger } from "effect-atom-jsx";
const count = Atom.make(0);
// Wrap to automatically log all reads/writes
const traced = AtomLogger.tracedWritable(count, "count");
// logs: atom:read { atom: "count", op: "read", value: "0" }
// logs: atom:write { atom: "count", op: "write", value: "5" }
// Effect-based logging
Effect.runSync(AtomLogger.logGet(count, "count"));
// Capture state snapshot
const snap = Effect.runSync(
AtomLogger.snapshot([["count", count], ["other", otherAtom]])
);
// { count: 0, other: "hello" }Create atoms whose values are continuously updated from Effect Streams or Queues.
import { Stream, Queue, Effect, Schedule } from "effect";
import { Atom } from "effect-atom-jsx";
// Atom fed by a Stream — starts a fiber on first read
const prices = Atom.fromStream(
Stream.fromIterable([10, 20, 30]),
0, // initial value
);
// Atom fed by a Queue
const queue = Effect.runSync(Queue.unbounded<string>());
const messages = Atom.fromQueue(queue, "");
// Atom fed by a Schedule (via Stream.fromSchedule)
const ticks = Atom.fromSchedule(Schedule.recurs(3), 0 as any);
// Stream recipe for UI text inputs (trim + length filtering)
const rawInput = Stream.make(" hello ", " ", "x", " world ");
const queryInput = Atom.Stream.textInput(rawInput, { minLength: 2 });
// Search-box recipe (text normalization + optional dedupe)
const searchTerms = Atom.Stream.searchInput(rawInput, {
minLength: 2,
lowercase: true,
});
// Both helpers return Effect Streams, so compose them into atoms.
function SearchBox() {
const [input, setInput] = createSignal("");
const results = Atom.fromStream(
Atom.Stream.searchInput(inputToStream(input), { minLength: 2, lowercase: true }),
[] as ReadonlyArray<string>,
);
return <input onInput={(e) => setInput((e.currentTarget as HTMLInputElement).value)} />;
}Render components to HTML strings on the server and hydrate on the client.
import {
renderToString, hydrateRoot, isServer,
setRequestEvent, getRequestEvent,
} from "effect-atom-jsx";
import { Hydration, Atom } from "effect-atom-jsx";
import * as Registry from "effect-atom-jsx/Registry";
// ─── Server ─────────────────────────────────────────────────────
setRequestEvent({ url: req.url, headers: req.headers });
const html = renderToString(() => <App />);
// Serialize atom state for the client
const registry = Registry.make();
const state = Hydration.dehydrate(registry, [
["count", countAtom],
["user", userAtom],
]);
res.send(`
<div id="root">${html}</div>
<script>window.__STATE__ = ${JSON.stringify(state)}</script>
`);
// ─── Client ─────────────────────────────────────────────────────
// Restore atom state from server
Hydration.hydrate(registry, window.__STATE__, {
count: countAtom,
user: userAtom,
});
// Optional validation hooks for development diagnostics:
Hydration.hydrate(registry, window.__STATE__, { count: countAtom, user: userAtom }, {
onUnknownKey: (key) => console.warn("Unknown hydration key:", key),
onMissingKey: (key) => console.warn("Missing hydration key:", key),
});
// Attach reactivity to existing DOM
const dispose = hydrateRoot(() => <App />, document.getElementById("root")!);JSX components for declarative conditional and list rendering:
| Component | Purpose | Example |
|---|---|---|
Show |
Conditional rendering | <Show when={show()}><p>Visible</p></Show> |
For |
List rendering with keying | <For each={items()}>{(item) => <li>{item}</li>}</For> |
Async |
Result pattern matching | <Async result={r} loading={...} success={...} /> |
Loading |
Show content while loading | <Loading when={result}><Spinner /></Loading> |
Errored |
Show content on error | <Errored result={r}>{(e) => <p>{e}</p>}</Errored> |
Switch / Match |
Multi-case matching | <Switch><Match when={a()}>A</Match>...</Switch> |
MatchTag |
Type-safe _tag matching |
<MatchTag value={r} cases={{ Success: ... }} /> |
Optional |
Render when value is truthy | <Optional when={val()}>{(v) => <p>{v}</p>}</Optional> |
MatchOption |
Match Effect Option | <MatchOption value={opt} some={(v) => ...} /> |
Dynamic |
Dynamic component selection | <Dynamic component={Comp} ...props /> |
WithLayer |
Provide a Layer boundary | <WithLayer layer={DbLive}>...</WithLayer> |
Frame |
Animation frame loop | <Frame>{() => <canvas />}</Frame> |
Primary modules are available as top-level namespace imports; advanced modules like Registry are deep-imported:
// Namespace import
import { Atom, AtomRef, Result, Hydration } from "effect-atom-jsx";
import { FetchResult } from "effect-atom-jsx"; // optional advanced compatibility
import { AtomSchema, AtomLogger, AtomRpc, AtomHttpApi } from "effect-atom-jsx";
// Deep imports
import * as Atom from "effect-atom-jsx/Atom";
import * as AtomSchema from "effect-atom-jsx/AtomSchema";
import * as Registry from "effect-atom-jsx/Registry";| Module | Key Exports |
|---|---|
Atom |
make, readable, writable, family, map, withFallback, projection, projectionAsync, withReactivity, invalidateReactivity, keepAlive, runtime, action, effect, pull, Stream.* (advanced OOO helpers), searchParam, kvs, flush, get, set, update, modify, refresh, subscribe, fromStream, fromQueue, query |
AtomRef |
make, collection |
Registry |
make (returns instance with get, set, update, modify, mount, refresh, subscribe, reset, dispose) |
Result |
loading, refreshing, success, failure, defect, match, map, flatMap, getOrElse, getOrThrow |
FetchResult |
initial, success, failure, isInitial, isSuccess, isFailure, isWaiting, fromResult, toResult, map, flatMap, match, all |
Hydration |
dehydrate, hydrate, toValues |
AtomSchema |
make, makeInitial, path, HtmlInput |
AtomLogger |
traced, tracedWritable, logGet, logSet, snapshot |
AtomRpc |
Tag() factory with query, mutation, refresh |
AtomHttpApi |
Tag() factory with grouped query, mutation, refresh |
import {
defineQuery, createQueryKey, invalidate,
isPending, latest,
createOptimistic, defineMutation,
useService, useServices, createMount, mount,
} from "effect-atom-jsx";
import {
atomEffect,
layerContext,
scopedRootEffect,
scopedQueryEffect,
scopedMutationEffect,
Result, Async,
} from "effect-atom-jsx/advanced";import {
createSignal, createEffect, createMemo, createRoot,
createContext, useContext,
onCleanup, onMount,
untrack, sample, flush,
mergeProps, splitProps,
getOwner, runWithOwner,
} from "effect-atom-jsx/advanced";batch(...) remains available for low-level runtime internals, but app code should rely on default microtask batching and use flush() only when deterministic sync ordering is required.
Full API reference: docs/API.md
Dedicated Effect integration guide: docs/ACTION_EFFECT_USE_RESOURCE.md
Effect-atom migration/equivalents guide: docs/EFFECT_ATOM_EQUIVALENTS.md
Architecture decisions (in progress): docs/adr/
| Example | Location | What it shows |
|---|---|---|
| Counter | examples/counter/ |
Signals, atoms, Registry, async data with atomEffect |
| Projection | examples/projection/ |
Atom.projection + Atom.projectionAsync with Async rendering |
| OOO Async | examples/ooo-async/ |
Atom.pull + OOO chunk merge, rendered via Async, Loading, and Errored |
| TodoMVC | examples/todomvc/ |
Full app with defineQuery, defineMutation, optimistic UI, service injection |
| RPC & HTTP API | examples/rpc-httpapi/ |
AtomRpc.Tag(), AtomHttpApi.Tag(), MatchTag component |
| Schema Form | examples/schema-form/ |
AtomSchema validation, touched/dirty/reset, AtomLogger.snapshot |
| SSR | examples/ssr/ |
renderToString, hydrateRoot, Hydration.dehydrate/hydrate |
Atom.runtime(layer)creates a runtime-bound API for reads (runtime.atom) and writes (runtime.action)- Effects inside runtime-bound atoms/actions resolve services via Effect context (
yield* Api) with requirements satisfied by the bound layer defineQuery()/atomEffect()run async effects reactively, exposingResultstatedefineMutation()remains the callback-style mutation alternative (optimistic/rollback hooks)- Component lifetimes are scope-backed: mount/root and component boundaries map to Effect scopes so parent disposal interrupts descendant fibers transitively
createMount(layer)+useService(Tag)remain the ambient-runtime alternative for simpler treesscopedRootEffect()/scopedQueryEffect()/scopedMutationEffect()are advanced Effect-first lifetime constructors- Babel compiles JSX to dom-expressions helpers — reactivity updates only the affected DOM nodes
DOM-free test harness via effect-atom-jsx/testing:
import { Effect } from "effect";
import { Atom, defineQuery, defineMutation, useService } from "effect-atom-jsx";
import { withTestLayer, renderWithLayer, mockService } from "effect-atom-jsx/testing";
const ApiMock = mockService(Api, {
load: () => Effect.succeed(42),
save: (_n: number) => Effect.void,
});
// Option 1: runtime-first testing (primary)
const testRuntime = Atom.runtime(ApiMock);
const users = testRuntime.atom(
Effect.gen(function* () {
const api = yield* Api;
return yield* api.load();
}),
);
await Effect.runPromise(Atom.result(users));
await testRuntime.dispose();
// Option 2: withTestLayer — manual ambient runtime execution
const harness = withTestLayer(ApiMock);
const result = harness.run(() => defineQuery(() => useService(Api).load(), { name: "load" }));
await harness.tick();
await harness.dispose();
// Option 3: renderWithLayer — runs UI immediately
const harness2 = renderWithLayer(ApiMock, () => {
const save = defineMutation((n: number) => useService(Api).save(n));
save.run(42);
});
await harness2.tick();
await harness2.dispose();See
docs/TESTING.mdfor the full testing guide.
Microtask batching is the default. Use flush() only when imperative DOM work needs synchronous commit ordering.
import { Atom } from "effect-atom-jsx";
function handleSubmit(button: HTMLButtonElement) {
const submitted = Atom.make(false);
submitted.set(true);
Atom.flush();
button.focus();
}This project provides an effect-atom-like ergonomic surface, implemented natively for Effect v4.
- Same: namespace-style API (
Atom,Result,Registry,AtomRef), atom graph patterns, waiting/revalidation async model - Different: native implementation tuned for JSX + dom-expressions, targets Effect v4 beta (vs v3)
- Guidance: if you already think in effect-atom terms, this API should feel familiar. Prefer
defineQuery/defineMutation/createMountfor Effect service integration.
- Runtime: Effect v4 beta (
effect@^4.0.0-beta.29) - JSX:
dom-expressionsviaeffect-atom-jsx/runtime - Test:
npm test/ Typecheck:npm run typecheck/ Build:npm run build