Designing a Shared Store Library for Svelte 5’s Runes Mode

Svelte
2025-08-24 23:31 (14 hours ago) ytyng

Conclusion

This is how I write it:

src/lib/stores/counter.svelte.ts

let counter = $state(0);

export default {
  get counter() {
    return counter;
  },
  increment: () => {
    counter += 1;
  }
};

Changes from Svelte 4 to Svelte 5

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

Major problem

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 approaches

Other ways to write it include:

  1. Wrapping in a function (officially recommended)
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();
  1. Using a class
class CounterStore {
  #count = $state(0);

  get count() {
    return this.#count;
  }

  increment() {
    this.#count += 1;
  }

  decrement() {
    this.#count -= 1;
  }
}

export const counter = new CounterStore();
  1. Mimicking Vue's .value with a getter/setter
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;
  1. Exporting an object’s properties
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.

Why my solution is good

My proposed approach has the following advantages:

  1. Minimal boilerplate
  2. No complex function wrappers or class definitions required
  3. Minimal TypeScript type declarations

  4. Natural encapsulation

  5. A getter exposes a read-only interface
  6. State can only be changed through methods
  7. Prevents unexpected direct mutations

  8. Intuitive to use

// usage
import counter from './stores/counter.svelte.ts';

console.log(counter.counter); // access via getter
counter.increment();          // modify via method
  1. Plays nicely with TypeScript
interface CounterStore {
  readonly counter: number;
  increment(): void;
  decrement(): void;
}

export default {
  get counter() { return counter; },
  increment: () => counter += 1,
  decrement: () => counter -= 1,
} satisfies CounterStore;

Result

With minimal boilerplate, it becomes:

src/lib/stores/counter.svelte.ts

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>

Benefits of Fine-grained Reactivity

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.

Final thoughts

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.

Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Archive

2025
2024
2023
2022
2021
2020
2019
2018
2017
2016
2015
2014
2013
2012
2011