Ilha for Svelte Developers
A practical map from Svelte $state, $derived, and $effect to ilha atoms and Effect derived atoms.
If you know Svelte, Ilha’s fine-grained reactivity should feel familiar. You write function components. You declare atom() values. Reads subscribe. Writes rerun the component and morph the DOM.
This is not an argument that Ilha is better than Svelte. It is a map from familiar Svelte ideas to Ilha.
What is Ilha?
Ilha is a small isomorphic UI library. A component renders HTML on the server and activates in the browser. You do not turn the whole page into a client app by default.
import { const atom: AtomFnatom } from "ilha";
export const const Counter: () => ViewCounter = () => {
const const count: AtomHandle<number>count = atom<number>(init: number | Atom<number> | Effect<number, unknown, AtomRegistry> | Stream<number, unknown, AtomRegistry>, options?: AtomOptions<number> | undefined): AtomHandle<number>atom(0);
return (
<JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton type?: string | undefinedtype="button" EventProps<HTMLButtonElement>.onclick?: ((event: Targeted<HTMLButtonElement, MouseEvent>) => void) | undefinedonclick={() => const count: AtomHandle<number>count.AtomHandle<number>.update: (f: (current: number) => number) => voidupdate((n: numbern) => n: numbern + 1)}>
{const count: AtomHandle<number>count}
</JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton>
);
};
$state vs atom()
Svelte reads and writes a variable. Ilha reads with count() and writes with .set() / .update(). In JSX, {count} subscribes.
import { const atom: AtomFnatom } from "ilha";
export const const Counter: () => ViewCounter = () => {
const const count: AtomHandle<number>count = atom<number>(init: number | Atom<number> | Effect<number, unknown, AtomRegistry> | Stream<number, unknown, AtomRegistry>, options?: AtomOptions<number> | undefined): AtomHandle<number>atom(0);
return (
<JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton type?: string | undefinedtype="button" EventProps<HTMLButtonElement>.onclick?: ((event: Targeted<HTMLButtonElement, MouseEvent>) => void) | undefinedonclick={() => const count: AtomHandle<number>count.AtomHandle<number>.update: (f: (current: number) => number) => voidupdate((n: numbern) => n: numbern + 1)}>
{const count: AtomHandle<number>count}
</JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton>
);
};
$derived vs Atom.map
import * as import AtomAtom from "effect/reactivity/Atom";
import { const atom: AtomFnatom } from "ilha";
export const const Totals: () => ViewTotals = () => {
const const count: AtomHandle<number>count = atom<number>(init: number | Atom.Atom<number> | Effect<number, unknown, AtomRegistry> | Stream<number, unknown, AtomRegistry>, options?: AtomOptions<number> | undefined): AtomHandle<number>atom(0);
const const doubled: AtomHandle<number>doubled = atom<number>(init: number | Atom.Atom<number> | Effect<number, unknown, AtomRegistry> | Stream<number, unknown, AtomRegistry>, options?: AtomOptions<number> | undefined): AtomHandle<number>atom(import AtomAtom.const map: <Atom.Atom<number>, number>(self: Atom.Atom<number>, f: (_: number) => number) => Atom.Atom<number> (+1 overload)Maps the current value of an atom with a pure function.
**Details**
When the source atom is writable, the returned atom remains writable and keeps
the source atom's write input type.map(const count: AtomHandle<number>count.AtomHandle<number>.atom: Atom.Atom<number>atom, (n: numbern) => n: numbern * 2));
return <JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>{const doubled: AtomHandle<number>doubled}</JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>;
};
Async and streamed data use when / Stream views, not a derived accessor with .loading.
$effect vs watch()
import { const atom: AtomFnatom, const watch: WatchFnwatch } from "ilha";
const const Title: () => ViewTitle = () => {
const const title: AtomHandle<string>title = atom<string>(init: string | Atom<string> | Effect<string, unknown, AtomRegistry> | Stream<string, unknown, AtomRegistry>, options?: AtomOptions<string> | undefined): AtomHandle<string>atom("Hello");
watch<string>(source: Atom<string> | AtomHandle<string> | Stream<string, unknown, unknown>, fn: WatchCallback<string>): Instruction<undefined>watch(const title: AtomHandle<string>title, (value: stringvalue) => {
var document: Document**`window.document`** returns a reference to the document contained in the window.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)document.Document.title: stringThe **`document.title`** property gets or sets the current title of the document. When present, it defaults to the value of the <title>.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/title)title = value: stringvalue;
});
return <JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>{const title: AtomHandle<string>title}</JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>;
};
Mount-only work uses watch.once() — return a cleanup for unmount. For stream-driven ongoing logic, use when with Atom.toStream — see Streams. For write-site DOM effects, handlers are fine.
Props
Props are ordinary function arguments. There is no $props() rune.
Boundaries
A nested function shares the parent component. mount / renderToString on a function makes it a root.
SSR
await renderToString(Counter) for HTML. mount(host, Counter, { hydrate: true }) to attach events.
| Svelte | Ilha |
|---|---|
$state |
atom(value) |
$derived |
atom(Atom.map(...)) or atom(Atom.transform(...)) |
$effect |
watch() / watch.once() or event handlers |
setContext |
createContext / context() |
+error |
ErrorBoundary (island) / router +error (route) |
bind:value |
oninput + atom |
.svelte component |
function component |