# 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](https://effect.website/) 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` where `E` is the error channel. Errors propagate through the component tree, and you **must** handle them before mounting: ```ts // 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 ``` Effex is explicit — what you write is what runs: ```ts // 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)}

Loading...

{:then user} {:catch error}

Error: {error.message}

{/await} ``` ```ts // 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 ``` ```ts // 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()` | 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 | | `` | `` | 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 ``` ```ts // 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
Total: ${total}
Total: ${total}
``` ```ts // Effex const Cart = (props: { items: Readable.Readable }) => 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 {#if isLoggedIn} {:else} {/if} ``` ```ts // Effex const Auth = (props: { isLoggedIn: Readable.Readable }) => when(props.isLoggedIn, { onTrue: () => Dashboard(), onFalse: () => Login(), }); ``` ### Lists ```svelte
    {#each todos as todo (todo.id)}
  • {todo.text}
  • {/each}
``` ```ts // Effex const TodoList = (props: { todos: Readable.Readable }) => each(props.todos, { container: () => $.ul(), key: (todo) => todo.id, render: (todo) => $.li({}, $.of(Readable.map(todo, (t) => t.text))), }); ``` ### Effects / Reactions ```svelte

{title}

{title}

``` ```ts // Effex const DocumentTitle = (props: { title: Readable.Readable; unreadCount: Readable.Readable; }) => 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
...
``` ```ts // Effex class ThemeService extends Context.Tag("Theme")() {} 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

You typed: {text}

``` ```ts // 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

Doubled: {$doubled}

``` ```ts // 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

Title

Card content

``` ```ts // Effex const Card = (props: { header?: Element.Element; children: Element.Element; }) => $.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)}

Loading...

{:then user} {:catch error}

Error: {error.message}

{/await} ``` ```ts // 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 1. **No compiler magic** — Svelte's `$:`, `$state`, `$derived` are compiler transforms. Effex is plain TypeScript — what you write is what runs. 2. **Explicit sources** — Svelte auto-tracks dependencies through compilation. Effex's `Readable.map` and `Readable.tap` require explicit readables to derive from or subscribe to. 3. **No special file format** — No `.svelte` files with ` {#if visible}
Fading content
{/if} ``` ```ts // 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-motion` by default ## Imperative DOM Access In Svelte, you use `bind:this` to get DOM element references: ```svelte ``` In Effex, `ref()` creates a pipeable element reference: ```ts // Effex const FocusInput = () => Effect.gen(function* () { const inputRef = yield* ref(); 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: ```ts 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.