Svelte 5: keep extracted rune state reactive

Reproduce a stale quantity after extracting rune state, distinguish getter reads from snapshots, and keep calculated totals in server-rendered output.
Make the extracted value stay live
The Svelte team’s stable-release announcement is dated 22 October 2024. Runes made reactive declarations available in components and .svelte.js modules. Moving state into a module still leaves an ordinary JavaScript question: did the consumer receive a way to read the current value, or just the value at the moment of extraction?
This historical Svelte 5.0.0 example is for developers who know component event handlers and are beginning to extract reactive logic. A quantity selector provides a small observable case. The pinned compiler, runtime and browser tests establish its behavior. They do not measure framework performance or implement a checkout.
Derive the total from the writable quantity
The component owns units. Clicking a button changes that state, and totalCents is calculated with $derived. No handler assigns the total separately. Integer cents keep the example arithmetic simple. The displayed amount is not an authoritative price, tax calculation or currency policy.
The compiled server output already contains Quantity: 1 and Total: 12.50 EUR. Hydration retains the original button node, then a click changes the total to 25.00 EUR. Retaining a reference to the server button matters: identical final text would not prove that the existing node survived hydration.
The browser fixture repeatedly activates the buttons. The upper limit is twenty units with a total of 250.00 EUR and a disabled Add one button. Repeated removals stop at one with Remove one disabled. The extracted factory below also needs direct method tests because callers can bypass buttons.
<script>
let units = $state(1);
const unitPriceCents = 1250;
let totalCents = $derived(units * unitPriceCents);
function changeUnits(delta) {
units = Math.min(20, Math.max(1, units + delta));
}
</script>
<p>Quantity: {units}</p>
<button type="button" onclick={() => changeUnits(-1)} disabled={units === 1}>
Remove one
</button>
<button type="button" onclick={() => changeUnits(1)} disabled={units === 20}>
Add one
</button>
<p aria-live="polite">Total: {(totalCents / 100).toFixed(2)} EUR</p>
Return a getter instead of a captured number
The factory closes over its own rune state. Its units getter reads that state whenever a consumer accesses quantity.units. The fixture mounts a host that renders that property and calls increment from a button. The displayed value changes from one to two while a separately created instance remains at seven.
Direct calls exercise both clamps without relying on disabled buttons. Seven invalid initial values are rejected: zero, twenty-one, a fraction, NaN, Infinity, a numeric string and null. Each produces the explicit RangeError. These checks describe this factory’s small input contract, not inventory availability or a server validation policy.
export function createQuantity(initial = 1) {
if (!Number.isInteger(initial) || initial < 1 || initial > 20) {
throw new RangeError("Initial quantity must be an integer from 1 to 20.");
}
let units = $state(initial);
return {
get units() {
return units;
},
increment() {
units = Math.min(20, units + 1);
},
decrement() {
units = Math.max(1, units - 1);
},
};
}
Two snapshots can fail at different boundaries
The first negative control keeps the factory intact but destructures units once in the host. After increment, quantity.units reads two while the destructured display still shows one. The state changed correctly. The consumer stopped reading it. Read quantity.units in the template or use a reactive derivation when the consumer needs a transformed value.
The second negative control changes the factory itself to return units as a plain property instead of a getter. Increment still changes the closed-over state, but reading the returned property gives one. Moving the template read cannot repair this version: the public object no longer exposes a live read. The fix belongs at the factory boundary.
I prefer the getter here because the caller has a compact object with explicit increment and decrement operations. A one-time primitive copy is useful when the caller wants a snapshot. It cannot remain connected to changing state.
Keep server-visible calculations out of effects
A third negative control replaces the derived total with state initialized to zero and an effect that updates it. Rendering the altered component on the server produces Total: 0.00 EUR. The unchanged derived version produces 12.50 EUR. An effect is not a second formula engine for content that must already be correct in the server response.
The Svelte documentation places effects at browser-side synchronization boundaries and describes cleanup for disposable work. That guidance supports uses such as subscriptions or imperative widgets. This fixture tests the server omission, not a real widget’s lifecycle or a subscription cleanup implementation.
Two factory calls create independent state in this fixture. They do not prove request isolation in a server application. Sharing one factory result as a module-level singleton would share that object with its consumers. I recommend deciding who owns each instance before moving user-specific state into a shared module.
Reproduce the behavior before migrating a larger component
Download and extract the source archive, then run python3 frontend/scripts/verify-svelte-5.py from its root with Docker and Python 3 available. Image construction downloads the locked dependencies. The browser execution has no external network, credentials or host-directory mounts.
The expected result contains ten checks, including the three altered versions. It records Svelte compiler 5.0.0, Node 24.20.0 and Chromium 153.0.8010.12. The compiler processes both published files. A plain JavaScript syntax check would miss the reactive behavior being tested.
I recommend using the appropriate maintained Svelte version for an application upgrade. This bundle reproduces the original synchronous rune model and does not verify newer asynchronous features, a SvelteKit data-loading flow, stock reservations or payment behavior. Keep those integration decisions separate from the demonstrated state and rendering boundaries.
Reproduce the examples
Exact component and rune module, locked compiler and browser, ten behavior checks and three altered versions.
Download the source and tests (.zip)
