I've been using Effect with SvelteKit, and the piece I kept missing was Effect Atom, the reactive state library that ships with Effect 4 as effect/reactivity. React and Vue have official bindings for it. Svelte didn't, so I wrote effect-atom-svelte.
This post walks through what using it looks like: a first atom, an async atom you can await in markup, and server rendering where the browser picks up the server's result instead of fetching again.
What's an atom?
An atom is a small piece of reactive state that lives outside your components. It can hold plain data, derive from other atoms, or run an Effect or a Stream. Atoms live in a registry, and any component that reads the same atom from the same registry sees the same value.
If you've used atoms with @effect/atom-react, your atoms, families, runtimes and RPC/HttpApi clients carry over unchanged. Only the hooks change.
Setup
npm install effect-atom-svelte "effect@~4.0.0"
The async hooks and server rendering use Svelte's async mode (Svelte 5.57+). In SvelteKit 3, turn it on in vite.config.ts:
// vite.config.ts
sveltekit({
compilerOptions: { experimental: { async: true } },
});
Then put a registry around the app in the root layout:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { RegistryProvider } from "effect-atom-svelte";
const { children } = $props();
</script>
<RegistryProvider>{@render children()}</RegistryProvider>
On the server, the provider creates a registry for each request, so two visitors never see each other's state.
Reading and writing atoms
<script module lang="ts">
import { Atom } from "effect/reactivity";
const countAtom = Atom.make(0);
const doubledAtom = Atom.make((get) => get(countAtom) * 2);
</script>
<script lang="ts">
import { useAtom, useAtomSet, useAtomValue } from "effect-atom-svelte";
const count = useAtom(countAtom);
const doubled = useAtomValue(doubledAtom);
const setCount = useAtomSet(countAtom);
</script>
<input type="number" bind:value={count.current} />
<button onclick={() => setCount((n) => n + 1)}>Add one</button>
<p>{count.current} × 2 = {doubled.current}</p>
The hooks return an object with a reactive current, the same convention as Svelte's own reactive classes. You can read it, assign to it or bind: to it. Two things to remember:
- Define atoms at module level, in
<script module>or a.tsfile. An atom made in a component's script would be a new atom for every instance. - Don't destructure:
const { current } = useAtomValue(atom)reads the value once and never updates.
Coming from React, the main difference is that a Svelte component's script runs once. So when the atom depends on a prop, pass a getter, such as useAtomValue(() => todoAtom(id)), and the hook follows whichever atom it returns.
Awaiting an async atom in markup
This is where Svelte 5's async support fits Effect nicely. An atom built from an Effect can be awaited right in the markup, and <svelte:boundary> handles the loading and failure states:
<script module lang="ts">
import { Effect } from "effect";
import { Atom } from "effect/reactivity";
const greetingAtom = Atom.make(
Effect.succeed("Hello from an Effect").pipe(Effect.delay("1 second"))
);
</script>
<script lang="ts">
import { useAtomRefresh, useAtomSuspense } from "effect-atom-svelte";
const greeting = useAtomSuspense(greetingAtom);
const refresh = useAtomRefresh(greetingAtom);
</script>
<svelte:boundary>
<p>{await greeting.current}</p>
<button onclick={refresh}>Load again</button>
{#snippet pending()}<p>Loading…</p>{/snippet}
{#snippet failed(error, reset)}
<p>Something went wrong: {String(error)}</p>
<button onclick={reset}>Try again</button>
{/snippet}
</svelte:boundary>
greeting.current is a promise of the value. When the Effect fails, the promise rejects and the boundary shows failed. In SvelteKit, the library's handleError hooks keep the Effect error's _tag, so your error page can still tell a NotFound from a Timeout.
Server rendering and hydration
This is the part I cared most about. Without hydration, the server renders a page with data, then the browser runs the same Effect again and fetches the data a second time.
To avoid that, give the atom a key and a schema with Atom.serializable:
// src/routes/todos/todos.ts
import { Effect, Schema } from "effect";
import { AsyncResult, Atom } from "effect/reactivity";
const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
type Todo = typeof Todo.Type;
// Stands in for a real request, such as an AtomRpc or AtomHttpApi query.
const fetchTodos: Effect.Effect<ReadonlyArray<Todo>> = Effect.succeed([
{ id: 1, title: "Read the docs" },
]).pipe(Effect.delay("200 millis"));
export const todosAtom = Atom.make(fetchTodos).pipe(
Atom.serializable({
key: "app/todos",
schema: AsyncResult.Schema({ success: Schema.Array(Todo) }),
})
);
Then await it with useAtomResult at the top of the page's script:
<!-- src/routes/todos/+page.svelte -->
<script lang="ts">
import { useAtomResult } from "effect-atom-svelte";
import { todosAtom } from "./todos.ts";
const todos = await useAtomResult(todosAtom);
</script>
{#if todos.current._tag === "Success"}
<ul>
{#each todos.current.value as todo (todo.id)}
<li>{todo.title}</li>
{/each}
</ul>
{:else if todos.current._tag === "Failure"}
<p>Could not load the todos.</p>
{/if}
The server waits for the first result, renders it and sends the encoded result along with the page. The browser decodes it and starts from there, so the Effect doesn't run again. After that, todos.current is a live AsyncResult, so refreshes and updates still come through.
Some things to know:
- Call these hooks before the script's first
await, or the browser misses the server's result. - The results are plain text in the page's HTML, so never serialize data the visitor shouldn't see.
- If you want the browser to fetch fresh data after hydrating anyway, pass
revalidateOnHydrate: true. The page shows the server's result until the browser's arrives.
The Hydration page has a live example of this, with atoms that record whether they ran on the server or again in the browser.
Where it's at
- It's 0.x. A minor release can change the API, and every change goes in the changelog.
-
It's pinned to
effect@~4.0.0, because it relies on some registry internals that could change in a minor Effect release. I plan to keep it up to date with each new Effect version. - It's a community project. It isn't part of Effect, and the Effect team doesn't make or endorse it. Longer term, I'd love to see it upstreamed as the official Svelte implementation, if the Effect team is open to that.
-
It needs Svelte 5.57+ with
experimental.asyncfor the async hooks and SSR. SvelteKit 3 is supported, SvelteKit 2 works with less detail in the error hooks, and plain Svelte works too.
Building it turned up a few bugs in Svelte's async mode and in Effect, and I've opened PRs upstream for them which many have since been merged.
How it was made
Most of the library's code and docs were written with AI (Claude), and so was a first draft of this post. I designed the API, directed the work and reviewed it, and the behavior is covered by tests that run in Chromium, Firefox and WebKit. I've checked the code in this post against the library.
Thanks to Thomas Foster, whose "Svelte Atoms" PR to effect-smol inspired the library, and to Kit Langton, whose Visual Effect inspired the live examples in the docs.
Links
- Docs, with live examples: atom.jarrednorris.dev
- Repo: github.com/jarrednorrisdev/effect-atom-svelte
- npm: effect-atom-svelte
If you try it, I'd love to hear how it goes. Issues and PRs are very welcome.
Top comments (1)
Việc kết hợp mô hình lập trình functional với reactivity của Svelte thực sự là một thử thách về mặt tư duy. Trước đây mình thường xuyên gặp rắc rối khi cố gắng quản lý side effects trong các store truyền thống mà không làm mất đi tính type-safe của Effect. Việc dùng Effect Atom để bridge khoảng cách giữa cơ chế reactive của Svelte và hệ sinh thái Effect giúp code trông gọn gàng hơn hẳn, đặc biệt là khi xử lý các stream dữ liệu phức tạp hoặc các lỗi bất đồng bộ cần được catch một cách tường minh. Bạn có thấy việc debug các atom này khi chạy trong môi trường SSR của SvelteKit có gặp khó khăn gì về trace stack không?