This is how I write it:
let counter = $state(0);
export default {
get counter() {
return counter;
},
increment: () => {
counter += 1;
}
};
Svelte 5 adopts Runes Mode for state management syntax.
In Svelte 4, inside a .svelte file you could write:
let counter = 0;
and it would implicitly be reactive.
When using a shared store, you used functions like writable and get, for example:
// stores.js
import { writable } from 'svelte/store';
export const count = writable(0);
export function increment() {
count.update(n => n + 1);
}
// Component.svelte
<script>
import { count, increment } from './stores.js';
</script>
<p>Count: {$count}</p>
<button on:click={increment}>+</button>
From Svelte 5, in addition to that style, a new syntax called Runes Mode is available.
This wraps variables that used to be implicitly reactive with explicit functions like $state()
, $derived()
, $effect()
, etc., providing a clearer and more consistent reactivity system. That makes it possible to create reactive state with the same syntax outside of .svelte files as well, achieving a sort of “universal reactivity.”
This change moves Svelte away from some of its traditional style and makes it feel closer to React or Vue. That has generated mixed reactions.
Critical opinions: - GitHub Discussion: Why this runes? It makes svelte look like React and I hate it — https://github.com/sveltejs/svelte/discussions/12455 - Loopwerk: First thoughts on Svelte 5's runes — https://www.loopwerk.io/articles/2025/svelte-5-runes/ — "not a big fan of the increased number of lines" - Medium: Svelte 5 runes— only dead fish follow the current — https://medium.com/@kimkorte/svelte-5-runes-only-dead-fish-follow-the-current-6e372a74918d
Positive opinions: - Fine-Grained Reactivity in Svelte 5 – Frontend Masters — https://frontendmasters.com/blog/fine-grained-reactivity-in-svelte-5/ - Svelte 5 2025 Review: Runes and Other Exciting New Features — https://www.scalablepath.com/javascript/svelte-5-review
A particularly big issue is the rule that you cannot export a reactive variable created with $state
.
If you try to export it, compilation stops with this error:
[plugin:vite-plugin-svelte-module] src/lib/stores/auth.svelte.ts:3:0 Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties
https://svelte.dev/e/state_invalid_export
This rule exists to preserve reference stability. Exporting a variable created with $state
directly can break the imported references when that variable is reassigned, potentially losing reactivity. To prevent that, Svelte 5 forbids direct exports and enforces safer ways to share state.
However, because of this rule, writing a shared store ends up looking like this:
From the official docs (https://svelte.jp/docs/svelte/$state)
let count = $state(0);
export function getCount() {
return count;
}
export function increment() {
count += 1;
}
Can you accept code like this, especially the existence of getCount
? I find it pretty rough. It gives off the stale smell of an aging Java developer.
Other ways to write it include:
function createCounter() {
let count = $state(0);
return {
get count() { return count; },
set count(v) { count = v; },
increment: () => count += 1,
decrement: () => count -= 1
};
}
export const counter = createCounter();
class CounterStore {
#count = $state(0);
get count() {
return this.#count;
}
increment() {
this.#count += 1;
}
decrement() {
this.#count -= 1;
}
}
export const counter = new CounterStore();
let _counter = $state(0);
export const counter = {
get value() { return _counter; },
set value(v) { _counter = v; }
};
export const increment = () => _counter += 1;
export const decrement = () => _counter -= 1;
export const store = $state({
counter: 0,
increment() { this.counter += 1; },
decrement() { this.counter -= 1; }
});
All of these require a lot of boilerplate compared to the old concise Svelte style, so it feels like Svelte’s compactness is being lost.
Also, when writing a shared store library, having many different recommended patterns is not great.
At least if the official docs had a convincingly good pattern, that would help — but I don’t feel that’s the case.
Given this, I still find Vue 3’s state + composables easier to work with.
My proposed approach has the following advantages:
Minimal TypeScript type declarations
Natural encapsulation
Prevents unexpected direct mutations
Intuitive to use
// usage
import counter from './stores/counter.svelte.ts';
console.log(counter.counter); // access via getter
counter.increment(); // modify via method
interface CounterStore {
readonly counter: number;
increment(): void;
decrement(): void;
}
export default {
get counter() { return counter; },
increment: () => counter += 1,
decrement: () => counter -= 1,
} satisfies CounterStore;
With minimal boilerplate, it becomes:
let counter = $state(0);
export default {
get counter() {
return counter;
},
increment: () => {
console.log("increment");
counter += 1;
}
};
In a .svelte component, you use it like this:
<script>
import counter from '$lib/stores/counter.svelte.ts';
</script>
<p>Count: {counter.counter}</p>
<button onclick={() => counter.increment()}>
Increment
</button>
Svelte 5 also introduces fine-grained reactivity, which tracks reactivity at the level of individual object properties. For example:
export const auth = $state({
statusLoaded: false,
statusLoading: false,
authenticatedUser: null
});
If auth.statusLoaded
changes, components that only use authenticatedUser
will not re-render. This is a significant performance improvement in Svelte 5.
Svelte 5’s Runes are technically a great improvement, but from a developer experience point of view the change is divisive. In particular, the new ways of writing shared stores have eroded some of Svelte’s previous simplicity.