Retrospective — Learning Astro and Svelte, Coming From C++/Qt
Retrospective
Published Jul 23, 2026
Part of: Workspace
Islands, coming from Qt
The first thing that took adjustment was how little of the site is
actually “running” at any given moment. Most of a page is plain
HTML with zero JavaScript attached — the Hub, a project narrative,
a note body, none of that ships a runtime unless something on the
page genuinely needs one. Only specific components hydrate into
live, interactive code, and only because a directive says so:
NavBar.astro mounts CommandPalette, Switcher, and
ThemeControl each with client:load; SearchIsland and
NotesPageIsland do the same on the Notes page. Nothing hydrates
by default — you opt a component in, explicitly, every time.
Coming from Qt, there’s no equivalent notion to reach for. A Qt
application is one process, and every widget in it is alive from
the moment the event loop starts — a QPushButton doesn’t have a
dormant, inert state waiting to be told to wake up; it’s already a
fully constructed object with live signal/slot connections before
the window is even shown. Astro’s islands architecture assumes the
opposite default: inert unless asked, and most of the page stays
that way permanently. It’s a model built around a question Qt never
has to ask, because Qt has no “static HTML” to fall back to in the
first place.
Reactivity without signals and slots
This project runs Svelte 5 (^5.56.6), but the components in it —
ThemeControl.svelte is a clear example — use the older reactivity
style: export let for props, plain let bindings for local state,
and $: expanded = hovered || focused || locked; for derived
values, not the newer $state/$derived runes. So the mental model
I actually had to build was: reassigning a variable is what the
compiler treats as a signal to re-render, not a runes-based
dependency graph.
That’s a different shape of thinking than Qt’s signal/slot system.
In Qt, reactivity is explicit and directional — you connect a signal
to a slot, and something fires because a specific emit happened.
In this Svelte style, there’s no connect() to write and no event
to name; you just assign to current or locked like an ordinary
variable, and the compiler has already instrumented that assignment
at build time to know it needs to patch the DOM. My first instinct
kept reaching for something to subscribe to. There wasn’t one —
the assignment is the notification.
The seam I didn’t expect: route.ts
The genuinely tricky part was src/stores/route.ts, and it only
made sense once I ran into the problem it exists to solve. The
Switcher island — the nav bar’s active-destination indicator — is
mounted with transition:persist="nav-switcher". That directive
tells Astro’s view transitions to keep the exact DOM node (and the
component instance inside it) across a client-side navigation,
instead of throwing it away and re-rendering it fresh. Great for
not re-mounting the nav bar on every page — but it means that
component’s props are frozen at whatever they were on first paint,
forever, for the life of the session. It never gets told the path
changed, because from Astro’s point of view, that subtree was never
re-rendered.
So the active-route highlight would just be wrong after the first
navigation, and nothing about the component itself was broken — the
props it needed simply never arrive a second time. The fix is a
tiny nanostores atom, currentPath, that the persisted Switcher
subscribes to directly, updated on the browser’s astro:page-load
event rather than through props. It’s a narrow, purpose-built escape
hatch: a normal, non-persisted island doesn’t need this at all,
because Astro re-renders those with correct server props every
time. The “aha” wasn’t nanostores itself — it was realizing that
transition:persist quietly creates components that Astro’s
rendering model can no longer talk to, and that anything living
inside one needs its own private channel if it cares about later
navigations at all.
What’s still fuzzy
Not everything clicked cleanly. I still don’t have a precise mental
model of exactly which islands get torn down and rebuilt versus
reused as-is across a view transition when transition:persist
isn’t involved — I know the persisted case cold now because I had
to debug it, but the default case I’m mostly trusting rather than
fully understanding. And the nanostores seam here is a single,
narrow instance; I haven’t yet had to reach for the same pattern
anywhere else, so I genuinely don’t know yet whether it generalizes
cleanly the next time something persisted needs live state, or
whether this one just happened to be simple.