Svelte 5 runes: a reactive store behind a synchronous read API
Building Verso I wanted components that never think
about where the data comes from. A shelf view should say “give me the books in
this library” and get an array back — synchronously, no await, no loading
prop threaded through five layers. The trick that made this work in Svelte 5 is
putting a reactive $state behind a plain object of getters.
One db object, getters over $state
The whole store is a module-level $state plus a db object whose properties
are getters that read it:
const state = $state<State>(loadLocal());
export const db = {
get books() {
return state.books;
},
get currentLibrary() {
return state.libraries.find((l) => l.id === state.currentLibraryId)
?? state.libraries[0];
},
inLibrary(libraryId = state.currentLibraryId) {
return state.books
.filter((b) => b.libraryId === libraryId)
.sort((a, b) => a.title.localeCompare(b.title, 'de'));
},
// ...
};
Because Svelte 5’s reactivity is fine-grained and runs through the getter every
time it’s read, a component can just write {#each db.inLibrary() as book} and it
re-renders when state.books changes. No subscription boilerplate, no
$store prefix, no store contract at all — reads look completely synchronous.
The reactivity is real; it’s just hidden behind a property access.
The same API, in two totally different modes
Here’s the payoff. That db object never changes shape, but underneath it runs
in one of two modes: local (anonymous, localStorage) or cloud
(logged-in, Supabase). A module-level mode flag decides where a write goes:
add(book) {
const b = { id: uid(), addedAt: Date.now(), ...book } as Book;
state.books.push(b); // optimistic: UI updates now
if (mode === 'cloud' && supabase)
cloudWrite(supabase.from('books').insert(toRow(b)));
else persistLocal();
return b;
}
The component calls db.add(...) and gets a book back immediately — it has no
idea whether that landed in localStorage or Postgres. Switching modes is just
swapping what backs the same state: useLocal() reloads from localStorage,
useCloud() pulls rows from Supabase and maps them in. The read API on top
doesn’t flinch.
Cloud writes are optimistic — the array mutates first, the network call fires after. If it fails (offline, an RLS rejection, a transient 5xx) I don’t want to silently roll back and confuse the user, so a tiny wrapper just flips an error flag the UI can surface:
function cloudWrite(p: PromiseLike<{ error: unknown }>) {
Promise.resolve(p).then(
(res) => { if (res?.error) status.error = true; },
() => { status.error = true; }
);
}
Bonus: one component, three HTML tags
Verso’s signature element is a book rendered as a shelf spine. Sometimes it’s a
link (to the detail page), sometimes a button (opens a sheet), sometimes purely
decorative (the spines on the landing page). Rather than three components, it’s
one, using <svelte:element> to pick its own tag:
<script lang="ts">
let { book, onclick, href, tone } = $props();
// link → <a>, handler → <button>, neither → decorative <div>
const tag = $derived(href ? 'a' : onclick ? 'button' : 'div');
const interactive = $derived(tag !== 'div');
</script>
<svelte:element this={tag} class="spine" class:deco={!interactive}
href={href || undefined} {onclick}
aria-hidden={interactive ? undefined : 'true'}>
<!-- ... -->
</svelte:element>
A decorative spine renders as a non-focusable <div> with aria-hidden, so the
landing page’s row of pretty book spines doesn’t pollute the tab order or the
screen-reader output. Same markup, correct semantics for each use.
The gotcha: dynamic type fights bind:value
The one that cost me a confusing ten minutes. The login form has a “show password” toggle, so I wanted:
<!-- ❌ Svelte won't allow this -->
<input type={showPw ? 'text' : 'password'} bind:value={password} />
Svelte refuses to compile a bind:value on an <input> whose type is a
dynamic expression. It makes sense once you see it: bind:value needs to know
the input’s type at compile time to generate the right coercion (a
type="number" binds a number, everything else binds a string). A runtime
type means it can’t know, so it bails.
The fix is to drop the two-way binding and wire it up manually — set value and
handle oninput yourself:
<!-- ✅ manual value + oninput -->
<input
type={showPw ? 'text' : 'password'}
value={password}
oninput={(e) => (password = e.currentTarget.value)}
/>
Slightly more verbose, but it lets the type flip freely. bind: is sugar; when
the sugar won’t compile, the desugared version always works.
The takeaway
The pattern I keep reaching for now: a reactive $state core, wrapped in a
plain object of getters and methods. Components read it like synchronous data
and never learn where it lives. That single seam is what let Verso run the same
UI over localStorage and Postgres — and made “log in and your books sync” a
change to the store, not to every screen that shows a book.