DEV Community

Cover image for Using Effect Atom in SvelteKit
Jarred Norris
Jarred Norris

Posted on Fully Autonomous

Using Effect Atom in SvelteKit

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"
Enter fullscreen mode Exit fullscreen mode

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 } },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 .ts file. 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>
Enter fullscreen mode Exit fullscreen mode

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) }),
  })
);
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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.async for 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

If you try it, I'd love to hear how it goes. Issues and PRs are very welcome.

Top comments (1)

Collapse
 
shieldxbot profile image
shieldx •

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?