Coming from Svelte
A guide for Svelte developers learning Effex. This covers the key differences, concept mapping, and side-by-side examples to help you transition.
This guide covers both Svelte 4 (reactive statements, stores) and Svelte 5 (runes).
Why Switch?
If you’re already using Effect in your application, Effex lets you use the same patterns and mental model across your entire stack. No more context-switching between Svelte’s compiler magic and Effect’s compositional approach.
Typed Error Handling
In Svelte, component errors are runtime surprises. There’s no built-in error boundary mechanism, and you typically rely on try/catch in event handlers or global error handling.
In Effex, every element has type Element<E, R> where E is the error channel. Errors propagate through the component tree, and you must handle them before mounting:
// This won't compile — UserProfile might fail with ApiError
mount(UserProfile(), document.body); // Type error!
// Handle the error first
mount(
Boundary.error(
() => UserProfile(),
(error) => $.div({}, $.of(`Failed to load: ${error.message}`)),
),
document.body,
); // Compiles
TypeScript tells you at build time which components can fail and forces you to handle it.
No Compiler Magic
Svelte’s power comes from its compiler — $: reactive statements, automatic subscriptions to stores, and runes in Svelte 5. This is elegant but opaque:
<!-- Svelte: Compiler transforms this -->
<script>
let count = 0; // Becomes reactive
$: doubled = count * 2; // Compiler creates derived value
</script>
<!-- Svelte 5 -->
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
Effex is explicit — what you write is what runs:
// Effex: No transformation
const count = yield* Signal.make(0);
const doubled = Readable.map(count, (c) => c * 2);
Benefits:
- Easier to debug (no compiled output to understand)
- Standard TypeScript tooling works perfectly
- No Svelte-specific IDE plugins needed
- Behavior is predictable and inspectable
Similar Reactivity Model
Both Svelte and Effex use fine-grained reactivity (not virtual DOM diffing). The concepts map fairly directly:
| Svelte 5 Rune | Svelte 4 | Effex |
|---|---|---|
$state() |
let x = ... |
Signal.make() |
$derived() |
$: x = ... |
Readable.map() |
$effect() |
$: { ... } |
Readable.tap() |
$props() |
export let |
Function parameters |
Async Story
<!-- Svelte -->
{#await fetchUser(id)}
<p>Loading...</p>
{:then user}
<UserProfile {user} />
{:catch error}
<p>Error: {error.message}</p>
{/await}
// Effex — Option 1: Boundary.suspense (one-shot)
Boundary.suspense({
render: () =>
Effect.gen(function* () {
const user = yield* fetchUser(id);
return yield* UserProfile({ user });
}),
fallback: () => $.div({}, $.of("Loading...")),
catch: (error) => $.div({}, $.of(`Error: ${error.message}`)),
delay: "200 millis", // Avoid loading flash — Svelte can't do this
});
// Effex — Option 2: AsyncReadable (reactive, with refetch)
const userData = yield* AsyncReadable.make(() => fetchUser(id));
// AsyncReadable has separate Readables for fine-grained reactivity
$.div(
{},
collect(
when(userData.isLoading, {
onTrue: () => $.div({}, $.of("Loading...")),
onFalse: () => $.span(),
}),
matchOption(userData.value, {
onSome: (user) => UserProfile({ user }),
onNone: () => $.span(),
}),
matchOption(userData.error, {
onSome: (err) => $.div({ class: "error" }, $.of(Readable.map(err, (e) => e.message))),
onNone: () => $.span(),
}),
),
);
The delay option on Boundary.suspense prevents flash of loading state for fast responses — something Svelte’s {#await} can’t do without manual work. AsyncReadable is better when you need refetch or reset capabilities.
Automatic Resource Cleanup
Svelte’s onDestroy requires manual cleanup registration. Effex uses Effect’s scope system:
<!-- Svelte -->
<script>
import { onDestroy } from 'svelte';
const subscription = eventSource.subscribe(handler);
onDestroy(() => subscription.unsubscribe());
</script>
// Effex: Automatic cleanup via scope
yield* eventSource.pipe(
Stream.runForEach(handler),
Effect.forkIn(scope), // Cleaned up when scope closes
);
Concept Mapping
| Svelte 5 | Svelte 4 | Effex | Notes |
|---|---|---|---|
$state(initial) |
let x = initial |
Signal.make(initial) |
Must yield* to create |
$derived(expr) |
$: x = expr |
Readable.map(dep, fn) |
Derives from a readable |
$effect(() => {}) |
$: { statement } |
Readable.tap(dep, fn) |
Automatic cleanup |
$props() |
export let prop |
Function parameters | Plain TypeScript |
$bindable() |
bind:value |
Signal + event handler | Explicit two-way binding |
getContext/setContext |
getContext/setContext |
yield* ServiceTag |
Effect services |
bind:this |
bind:this |
ref<T>() |
For DOM element refs |
{#if} {:else} |
{#if} {:else} |
when(cond, { onTrue, onFalse }) |
Object config |
{#if x != null} |
{#if x != null} |
matchOption(optX, { onSome, onNone }) |
Unwraps Option |
{#each} |
{#each} |
each(arr, { key, render }) |
Key function required |
{#await} |
{#await} |
Boundary.suspense or AsyncReadable |
Multiple options |
on:click |
on:click |
onClick |
Camel case handlers |
class:active={x} |
class:active={x} |
class prop with Readable |
Different syntax |
<svelte:component> |
<svelte:component> |
Dynamic function call | Just call the component |
.svelte files |
.svelte files |
Plain .ts files |
No special file format |
Stores (writable) |
Stores | Signal |
Similar concept |
Side-by-Side Examples
State and Updates
<!-- Svelte 5 -->
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
<!-- Svelte 4 -->
<script>
let count = 0;
</script>
<button on:click={() => count++}>{count}</button>
// Effex
const Counter = () =>
Effect.gen(function* () {
const count = yield* Signal.make(0);
return yield* $.button(
{ onClick: () => count.update((c) => c + 1) },
$.of(count),
);
});
Derived State
<!-- Svelte 5 -->
<script>
let items = $state([]);
let total = $derived(items.reduce((sum, i) => sum + i.price, 0));
</script>
<div>Total: ${total}</div>
<!-- Svelte 4 -->
<script>
let items = [];
$: total = items.reduce((sum, i) => sum + i.price, 0);
</script>
<div>Total: ${total}</div>
// Effex
const Cart = (props: { items: Readable.Readable<Item[]> }) =>
Effect.gen(function* () {
const total = Readable.map(props.items, (items) =>
items.reduce((sum, i) => sum + i.price, 0),
);
return yield* $.div({}, t`Total: $${total}`);
});
Conditional Rendering
<!-- Svelte -->
<script>
let isLoggedIn = $state(false);
</script>
{#if isLoggedIn}
<Dashboard />
{:else}
<Login />
{/if}
// Effex
const Auth = (props: { isLoggedIn: Readable.Readable<boolean> }) =>
when(props.isLoggedIn, {
onTrue: () => Dashboard(),
onFalse: () => Login(),
});
Lists
<!-- Svelte -->
<script>
let todos = $state([]);
</script>
<ul>
{#each todos as todo (todo.id)}
<li>{todo.text}</li>
{/each}
</ul>
// Effex
const TodoList = (props: { todos: Readable.Readable<Todo[]> }) =>
each(props.todos, {
container: () => $.ul(),
key: (todo) => todo.id,
render: (todo) =>
$.li({}, $.of(Readable.map(todo, (t) => t.text))),
});
Effects / Reactions
<!-- Svelte 5 -->
<script>
let title = $state('My App');
let unreadCount = $state(0);
$effect(() => {
document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title;
});
$effect(() => {
localStorage.setItem('lastTitle', title);
});
</script>
<h1>{title}</h1>
<!-- Svelte 4 -->
<script>
let title = 'My App';
let unreadCount = 0;
$: document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title;
$: localStorage.setItem('lastTitle', title);
</script>
<h1>{title}</h1>
// Effex
const DocumentTitle = (props: {
title: Readable.Readable<string>;
unreadCount: Readable.Readable<number>;
}) =>
Effect.gen(function* () {
const combined = Readable.zipWith(props.title, props.unreadCount, (title, count) =>
count > 0 ? `(${count}) ${title}` : title,
);
yield* Readable.tap(combined, (t) =>
Effect.sync(() => { document.title = t; }),
);
yield* Readable.tap(props.title, (title) =>
Effect.sync(() => localStorage.setItem("lastTitle", title)),
);
return yield* $.h1({}, $.of(props.title));
});
Context (Services)
<!-- Svelte Parent -->
<script>
import { setContext } from 'svelte';
setContext('theme', 'dark');
</script>
<!-- Svelte Child -->
<script>
import { getContext } from 'svelte';
const theme = getContext('theme');
</script>
<div class={theme}>...</div>
// Effex
class ThemeService extends Context.Tag("Theme")<ThemeService, string>() {}
const Page = () =>
Effect.gen(function* () {
const theme = yield* ThemeService;
return yield* $.div({ class: theme }, $.of("..."));
});
// Provide at mount
runApp(mount(Page().pipe(Effect.provideService(ThemeService, "dark")), root));
// Or provide inline
$.div(
{ class: "app" },
provide(ThemeService, "dark", Page()),
);
Two-Way Binding
<!-- Svelte -->
<script>
let text = $state('');
</script>
<input bind:value={text} />
<p>You typed: {text}</p>
// Effex
const TextInput = () =>
Effect.gen(function* () {
const text = yield* Signal.make("");
return yield* $.div(
{},
collect(
$.input({
value: text,
onInput: (e) => text.set((e.target as HTMLInputElement).value),
}),
$.p({}, t`You typed: ${text}`),
),
);
});
Stores (Svelte 4)
<!-- Svelte 4 with stores -->
<script>
import { writable, derived } from 'svelte/store';
const count = writable(0);
const doubled = derived(count, $count => $count * 2);
</script>
<button on:click={() => $count++}>{$count}</button>
<p>Doubled: {$doubled}</p>
// Effex
const Counter = () =>
Effect.gen(function* () {
const count = yield* Signal.make(0);
const doubled = Readable.map(count, (c) => c * 2);
return yield* $.div(
{},
collect(
$.button({ onClick: () => count.update((c) => c + 1) }, $.of(count)),
$.p({}, t`Doubled: ${doubled}`),
),
);
});
Slots / Children
<!-- Svelte -->
<Card>
<h1 slot="header">Title</h1>
<p>Card content</p>
</Card>
<!-- Card.svelte -->
<div class="card">
<slot name="header" />
<slot />
</div>
// Effex
const Card = <E, R>(props: {
header?: Element.Element<HTMLElement, E, R>;
children: Element.Element<HTMLElement, E, R>;
}) =>
$.div(
{ class: "card" },
collect(
props.header ?? $.span(),
props.children,
),
);
// Usage
Card({
header: $.h1({}, $.of("Title")),
children: $.p({}, $.of("Card content")),
});
Async / Await Blocks
<!-- Svelte -->
{#await fetchUser(id)}
<p>Loading...</p>
{:then user}
<UserProfile {user} />
{:catch error}
<p>Error: {error.message}</p>
{/await}
// Effex
Boundary.suspense({
render: () =>
Effect.gen(function* () {
const user = yield* fetchUser(id);
return yield* UserProfile({ user });
}),
fallback: () => $.p({}, $.of("Loading...")),
catch: (e) => $.p({}, $.of(`Error: ${e}`)),
});
Key Mindset Shifts
-
No compiler magic — Svelte’s
$:,$state,$derivedare compiler transforms. Effex is plain TypeScript — what you write is what runs. -
Explicit sources — Svelte auto-tracks dependencies through compilation. Effex’s
Readable.mapandReadable.taprequire explicit readables to derive from or subscribe to. -
No special file format — No
.sveltefiles with<script>,<style>, and template sections. Just TypeScript. -
Errors are values — Instead of try/catch everywhere, errors flow through the type system. Handle them explicitly with
Boundary.error. -
No bind: directive — Two-way binding is explicit with a value prop and event handler. More verbose but clearer data flow.
-
Cleanup is automatic — Effect’s scope system handles resource cleanup. No need to remember
onDestroy. -
Function calls, not templates —
{#if}becomeswhen(),{#each}becomeseach(). It’s all TypeScript.
Custom Equality
In Svelte, reactivity is based on assignment. For objects, you often need to reassign to trigger updates, and there’s no way to customize equality checking.
In Effex, equality is a first-class option on every reactive primitive:
// Only trigger updates when the user ID changes, ignoring lastSeen timestamps
const currentUser = yield* Signal.make<User>(
{ id: 1, name: "Alice", lastSeen: new Date() },
{ equals: (a, b) => a.id === b.id },
);
Transitions and Animations
Svelte has built-in transition directives. Effex uses CSS-first animations:
<!-- Svelte -->
<script>
import { fade, slide } from 'svelte/transition';
</script>
{#if visible}
<div transition:fade>Fading content</div>
{/if}
// Effex
when(visible, {
onTrue: () => $.div({}, $.of("Fading content")),
onFalse: () => $.span(),
animate: {
enter: "fade-in", // CSS class
exit: "fade-out", // CSS class
},
});
Effex’s approach:
- Uses standard CSS animations (better performance, GPU-accelerated)
- Works with any CSS framework (Tailwind, etc.)
- Supports staggered list animations
- Respects
prefers-reduced-motionby default
Imperative DOM Access
In Svelte, you use bind:this to get DOM element references:
<!-- Svelte -->
<script>
let inputEl;
function handleFocus() {
inputEl?.focus();
inputEl?.scrollIntoView({ behavior: 'smooth' });
inputEl?.classList.add('focused');
}
</script>
<input bind:this={inputEl} on:click={handleFocus} />
In Effex, ref() creates a pipeable element reference:
// Effex
const FocusInput = () =>
Effect.gen(function* () {
const inputRef = yield* ref<HTMLInputElement>();
const handleFocus = () =>
inputRef.pipe(
Element.focus,
Element.scrollIntoView({ behavior: "smooth" }),
Element.addClass("focused"),
);
return yield* $.input({ ref: inputRef, onClick: handleFocus });
});
Common Svelte DOM Patterns
| Svelte Pattern | Effex Equivalent |
|---|---|
el?.focus() |
el.pipe(Element.focus) |
el?.blur() |
el.pipe(Element.blur) |
el?.click() |
el.pipe(Element.click) |
el?.scrollIntoView() |
el.pipe(Element.scrollIntoView()) |
el?.classList.add("x") |
el.pipe(Element.addClass("x")) |
el?.classList.remove("x") |
el.pipe(Element.removeClass("x")) |
el?.classList.toggle("x") |
el.pipe(Element.toggleClass("x")) |
el?.setAttribute("k", "v") |
el.pipe(Element.setAttribute("k", "v")) |
el?.dataset.state = "x" |
el.pipe(Element.setData("state", "x")) |
el?.style.color = "red" |
el.pipe(Element.setStyle("color", "red")) |
el?.querySelector(".x") |
el.pipe(Element.querySelector(".x")) |
Animation Hooks with Element Helpers
Effex’s animation system passes elements to lifecycle hooks, letting you use Element helpers:
when(isModalOpen, {
onTrue: () => Modal(),
onFalse: () => $.span(),
animate: {
enter: "fade-in",
exit: "fade-out",
onEnter: (el) => el.pipe(Element.focusFirst("[data-autofocus]")),
onBeforeExit: (el) => el.pipe(Element.blur),
},
});
This is similar to Svelte’s in:, out: transition directive hooks but uses pipeable operations for composability.