svelte-effect-runtime

Best practices

Best practices

A short list of patterns that come up repeatedly in production svelte-effect-runtime apps. The runnable companion is examples/sveltekit/ in the repo.

Tagged errors via Data.TaggedError

Use Data.TaggedError for every domain failure your remote functions emit. The tag survives the wire, so the client can recover with Effect.catchTag("Tag", err => ...) and read the typed payload fields directly.

You don't have to colocate error classes — but a src/lib/errors.ts module keeps tags consistent across remotes. The client's type inference works either way.

import { Data } from "effect";

export class PostNotFound extends Data.TaggedError("PostNotFound")<{
  readonly slug: string;
}> {}

yield*, not await

Inside <script effect>, all remote calls return Effects. Use yield* to resolve them; await will not work — Effects don't have a .then.

<script lang="ts" effect>
  import { get_posts } from "$lib/posts.remote";

  // ✅ correct — top-level yield* is rewritten by the preprocessor
  const posts = yield* get_posts();

  // ❌ wrong — Effects are not awaitable
  // const posts = await get_posts();
</script>

The yield* rewrite only fires in three places: top-level statements of <script effect>, bodies of Effect.gen(function* () { ... }), and inline-arrow event handlers. Wrapping it in function foo() { yield* ... } will hit a JS parser error before the preprocessor even sees it.

<!-- ✅ inline arrow — preprocessor lowers the yield* -->
<button onclick={() => { yield* increment(1); }}>+1</button>

<!-- ✅ explicit Effect.gen — works because gen accepts yield* -->
<script lang="ts" effect>
  const increment_twice = Effect.gen(function* () {
    yield* increment(1);
    yield* increment(1);
  });
</script>

Compose with Effect operators on submit(data)

form.submit(data) returns Effect<Output, RemoteFailure<Error>, never> — pipe it through any Effect combinator you'd use elsewhere:

const slug = yield* create_post.submit({ title, body }).pipe(
  Effect.tap((post) => Effect.log(`created ${post.slug}`)),
  Effect.catchTag("TitleTooShort", () => Effect.succeed("draft"))
);

The same goes for Query, Command, and the new (1.6.0) form.validate() and form.enhance(callback) callback.

Keep request-scoped data out of ServerRuntime

ServerRuntime.make(...) is for long-lived services: database pools, loggers, configuration. Read per-request values (cookies, headers, the route, the current user) from RequestEvent inside the Effect instead:

export const get_session = Query(() =>
  Effect.gen(function* () {
    const event = yield* RequestEvent;
    const users = yield* UserRepository;     // from ServerRuntime
    const session_id = event.cookies.get("session_id");  // from request
    return yield* users.find_by_session(session_id);
  })
);

<form {...form}> for everything that can submit

The 1.5.0+ wrapper makes <form {...form}> bit-identical to spreading SvelteKit's native form(). Prefer it over Command: forms degrade gracefully without JavaScript, and the spread wires up progressive-enhancement plus the attachment that intercepts submission via fetch.

Use form.submit(data) when there's genuinely no <form> (wizard modals, keyboard shortcuts) — same Effect-shape either way.

On this page