Reactive state management for Effect.js + React
Source: .repos/effect-atom (vendored monorepo)
Packages: @effect-atom/atom, @effect-atom/atom-react
Atoms are reactive Effect containers. Think of them as:
- Reactive
Refs that notify subscribers on change - Lazy computed values with automatic dependency tracking
- Safe wrappers around async/effectful computations
- Nodes in a reactive dependency graph
Component subscribes → Atom computes → Dependencies tracked → Re-render on change
Effect atoms don't return raw values. They return Result:
type Result<A, E> = Initial | Success<A> | Failure<E>
// Pattern matching
Result.builder(result)
.onInitial(() => <Loading />)
.onSuccess((data) => <Content data={data} />)
.onFailure((error) => <Error error={error} />)
.render()
// Direct access (throws on Initial/Failure)
Result.getOrThrow(result)| Type | Description | Example |
|---|---|---|
Atom<A> |
Read-only reactive value | Atom.make((get) => get(other) * 2) |
Writable<R, W> |
Read + write | Atom.make(0) (primitive) |
Atom<Result<A, E>> |
Effect-backed async | runtime.atom(Effect.gen(...)) |
const atom1 = Atom.make(0);
const atom2 = Atom.make(0);
// atom1 !== atom2 — different atoms!
// Use Atom.family for stable references
const userAtom = Atom.family((id: string) => Atom.make(fetchUser(id)));
userAtom("123") === userAtom("123"); // true — same reference// Create shared atom context with global layers
export const makeAtomRuntime = Atom.context({
memoMap: Atom.defaultMemoMap,
});
// Add global services (logging, config)
makeAtomRuntime.addGlobalLayer(
Layer.provideMerge(Logger.pretty, Logger.minimumLogLevel(LogLevel.Debug))
);// 1. Build a runtime with required services
const runtime = makeAtomRuntime(
Layer.mergeAll(GovernanceComponent.Default, SendTransaction.Default).pipe(
Layer.provideMerge(RadixDappToolkit.Live),
Layer.provide(Config.StokenetLive)
)
);
// 2. Create atoms that use those services
export const temperatureChecksAtom = runtime.atom(
Effect.gen(function* () {
const governance = yield* GovernanceComponent;
return yield* governance.getTemperatureChecks();
})
);For atoms that execute effects with arguments:
export const voteAtom = runtime.fn(
Effect.fn(
function* (input: VoteInput) {
const governance = yield* GovernanceComponent;
return yield* governance.vote(input);
},
withToast({
whenLoading: "Submitting vote...",
whenSuccess: "Vote submitted",
whenFailure: ({ cause }) => Option.some("Failed"),
})
)
);
// Usage in component
const vote = useAtomSet(voteAtom);
vote({ temperatureCheckId, vote: "For" });export const getTemperatureCheckByIdAtom = Atom.family(
(id: TemperatureCheckId) =>
runtime.atom(
Effect.gen(function* () {
const governance = yield* GovernanceComponent;
return yield* governance.getTemperatureCheckById(id);
})
)
);
// Usage — same ID returns same atom instance
const tc = useAtomValue(getTemperatureCheckByIdAtom(id));export const votesForConnectedAccountsAtom = Atom.family(
(kvsAddress: KeyValueStoreAddress) =>
runtime.atom(
Effect.fnUntraced(function* (get) {
// Subscribe to accountsAtom — reruns when accounts change
const accounts = yield* get.result(accountsAtom);
const governance = yield* GovernanceComponent;
return yield* governance.getVotes({ kvsAddress, accounts });
})
)
);// Basic read
const checks = useAtomValue(temperatureChecksAtom);
// With selector/transform
const count = useAtomValue(temperatureChecksAtom, (result) =>
Result.map(result, (checks) => checks.length)
);
// Unwrap Result (throws on Initial/Failure)
const data = useAtomValue(atom, Result.getOrThrow);// Get setter function
const setCount = useAtomSet(countAtom);
setCount(10); // direct value
setCount((c) => c + 1); // updater function
// For function atoms (runtime.fn)
const vote = useAtomSet(voteAtom);
vote({ temperatureCheckId, vote: "For" });const [value, setValue] = useAtom(countAtom);<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
function DataComponent() {
// Throws promise while Initial — triggers Suspense
const data = useAtomSuspense(asyncAtom)
return <div>{data}</div>
}const refresh = useAtomRefresh(temperatureChecksAtom);
// Call after mutations to refetch
refresh();Higher-order function that wraps Effect atoms with toast notifications:
export const myAtom = runtime.fn(
Effect.fn(
function* (input: Input) {
/* ... */
},
withToast({
whenLoading: "Processing...",
whenSuccess: "Done!",
// or dynamic: ({ result }) => `Created ${result.id}`
whenFailure: ({ cause }) => {
if (cause._tag === "Fail") {
if (cause.error instanceof MyCustomError) {
return Option.some(cause.error.message);
}
}
return Option.some("Something went wrong");
},
})
)
);Use Data.TaggedError for typed error handling:
export class AccountAlreadyVotedError extends Data.TaggedError(
"AccountAlreadyVotedError"
)<{ message: string }> {}
// In atom
if (alreadyVoted) {
return (
yield *
new AccountAlreadyVotedError({
message: "Already voted",
})
);
}
// In toast handler
whenFailure: ({ cause }) => {
if (cause._tag === "Fail") {
if (cause.error instanceof AccountAlreadyVotedError) {
return Option.some(cause.error.message);
}
}
return Option.some("Failed");
};Atoms are garbage-collected when no subscribers. Use keepAlive for persistent state:
const persistentAtom = Atom.make(0).pipe(Atom.keepAlive);Control cleanup delay:
const atomWithDelay = Atom.make(value).pipe(Atom.setIdleTTL(1000));Cleanup resources when atom is disposed:
const scrollAtom = Atom.make((get) => {
const handler = () => get.setSelf(window.scrollY);
window.addEventListener("scroll", handler);
get.addFinalizer(() => window.removeEventListener("scroll", handler));
return window.scrollY;
});function MyComponent() {
const result = useAtomValue(myAsyncAtom)
return Result.builder(result)
.onInitial(() => <Skeleton />)
.onSuccess((data) => <DataView data={data} />)
.onFailure((error) => <ErrorMessage error={error} />)
.render()
}const allVoted = Result.builder(votesResult)
.onSuccess((votes) =>
accounts.every((acc) => votes.some((v) => v.address === acc.address))
)
.onInitial(() => false)
.onFailure(() => false)
.render();
if (allVoted) return null;runtime.atom(
Effect.fnUntraced(function* (get) {
// Wait for auth
const user = yield* get.result(userAtom);
// Then fetch user-specific data
const service = yield* MyService;
return yield* service.getDataForUser(user.id);
})
);| Function | Use Case |
|---|---|
Atom.make(value) |
Simple writable atom |
Atom.make((get) => ...) |
Derived/computed atom |
runtime.atom(Effect.gen(...)) |
Async Effect-backed atom |
runtime.fn(Effect.fn(...)) |
Function atom with args |
Atom.family((arg) => atom) |
Parameterized atom factory |
Atom.map(atom, fn) |
Transform atom value |
| Modifier | Effect |
|---|---|
.pipe(Atom.keepAlive) |
Prevent GC |
.pipe(Atom.setIdleTTL(ms)) |
Custom cleanup delay |
.pipe(Atom.withLabel("name")) |
Debug label |
| Hook | Purpose |
|---|---|
useAtomValue(atom) |
Subscribe and read |
useAtomSet(atom) |
Get setter function |
useAtom(atom) |
[value, setter] tuple |
useAtomSuspense(atom) |
Suspense integration |
useAtomRefresh(atom) |
Force re-computation |
useAtomMount(atom) |
Keep atom alive |
| Function | Purpose |
|---|---|
Result.isInitial(r) |
Check loading state |
Result.isSuccess(r) |
Check success |
Result.isFailure(r) |
Check error |
Result.getOrThrow(r) |
Unwrap or throw |
Result.getOrElse(r, default) |
Unwrap or default |
Result.map(r, fn) |
Transform success value |
Result.builder(r) |
Pattern matching builder |