tl;dr
ES6 classes seem to make good Svelte stores:
No framework involvement other than using
$state()
to mark public properties reactive is necessaryES6 getters, setters, and functions replace derived state, mutations, and actions
A Svelte version of the Pinia example with props, derived state, and actions is detailed below and available in GitHub
Back in 2012, Brian Kotek and I lamented our move from 🪦 Adobe Flex to JavaScript. We’d been mucking with the ideas of global state/stores/MVVM for years in Flash, and AngularJS’s “hey, it’s one big object” approach to state gave me fits.
Making a long series of complaining noises short: I just wanted a JavaScript framework that’d let me mark some value reactive/observable. Basically, I wanted an anti-framework, and to rely on language features instead of learning framework-specific stuff.
Svelte 5 looks to be there. In this problem space, it lets me mark individual values as reactive, and then it disappears entirely.
Even better, its runes (like $state()) work outside of components. This means that we can use native language features, like classes, with Svelte sugar!
So what’s a store?
Definitions will vary. For our scope: a store is “something” that holds globally-available data (“state”) outside of our components.
Any component should be able to bind to state held by the store:
<div>{ userStore.currentUser.firstName }</div>
Any component should be able to ask the store to update state (components do not update state directly):
<button onclick={userStore.logout}>Eject!</button>
If this sounds like old-school presentation model/MVVM stuff, I’m right there with you.
What do I need it to do?
My needs are pretty simple. I want a store that:
Holds public reactive state and makes it available to components
Holds private state internally
Encapsulates calculation of derived state (e.g., a filtered list of items) as if it’s a simple bindable property
Exposes actions (public methods) that allow components to invoke logic that may or may not update state
This sounds a lot like an old-school, OOP-isn’t-evil class, just with some observable/reactive public properties.
I started this blog entry wondering if I could get all of this with a plain ES6 class decorated with Svelte $state()
runes, and it looks like I can:
Public reactive state: public properties with initial values wrapped in
$state()
Private state: private properties
Derived state: ES6 getters have proven reactive (this surprised me)
Actions: plain old public methods that update properties.
A realistic example
Let’s compare a plain Svelte ES6 store with a store written with Pinia for Vue 3.
We’ll rebuild “the more realistic example” from their docs as an ES6-based class for Svelte. It’s a good example of great documentation, succinctly showing how Pinia meets all four of our needs.
We could use a React example, but I just don’t have the time to decide between Redux, Zustand, Valtio, and Jotai. My answer would be “Mobx,” because it largely caused this blog entry ;).
Public and private state
In Pinia, we import Pinia-specific functions. Everything within the magic state
key is reactive, but you have to remember that it’s functional (lots of symbols). The magic string anti-pattern is used to identify the store instead of an exported identifier.
import { defineStore } from 'pinia'
export const useTodos = defineStore('todos', {
state: () => ({
/** @type {{ text: string, id: number, isFinished: boolean }[]} */
todos: [],
/** @type {'all' | 'finished' | 'unfinished'} */
filter: 'all',
// type will be automatically inferred to number
nextId: 0,
}),
}
Moving over to Svelte and ES6 (and, in this case, TypeScript), we can do the same thing with plain-old properties, using the $state() rune to mark only certain things reactive:
class TodoStore {
// We'll keep the list of Todos private...
private _todos: Todo[] = $state([])
// ...but we can still go loosey-goosey public string with filter
filter: string = $state('all')
// A cheap id: kept private!
private nextId = 0
}
In my book, this is a big win for Svelte: language features do all of the real lifting, not frameworks. We can also more cleanly “hide” internal state with as-private-as-JavaScript-can-be properties.
Derived state
In Pinia, derived state is accomplished via framework-specific “getters” declared within a store. I always get tripped up in these, because they’re provided a `state` argument but also reference `this`, and the two are not equivalent. When I’m working in it daily, I can keep it straight, but crossing wires can lead to subtle issues. Here’s Pinia creating the filtered list of to-dos:
getters: {
finishedTodos(state) {
// autocompletion! ✨
return state.todos.filter((todo) => todo.isFinished)
},
unfinishedTodos(state) {
return state.todos.filter((todo) => !todo.isFinished)
},
/**
* @returns {{ text: string, id: number, isFinished: boolean }[]}
*/
filteredTodos(state) {
if (this.filter === 'finished') {
// call other getters with autocompletion ✨
return this.finishedTodos
} else if (this.filter === 'unfinished') {
return this.unfinishedTodos
}
return this.todos
},
},
That’s….a lot.
Back over in ES6 - I can’t even call it Svelte, because it isn’t, and that’s the point! - this is where I was surprised when things “just worked.”
We can accomplish the same thing with a very plain property using getter functions and no framework-specific code:
get todos():Todo[] {
switch( this.filter ) {
case 'finished': return this._todos.filter(it => it.isFinished)
case 'unfinished': return this._todos.filter(it => !it.isFinished)
default: return this._todos
}
}
Components can simply bind to it:
{#each todos as todo(todo.id)}...
I can’t entirely tell you why it works, as I expected to have to use a `$effect()` rune. However, it does make me very happy.
(If any reader can chime in, I’d love to know the mechanics and pitfalls.)
Actions and “mutations”
Over in the Pinia example, we can see that actions are added to a store, and that they can “mutate” (update) state. These weren’t an issue for me, and I was very glad when Pinia replaced the Vuex separation between “action” and “mutation” with “actions are now both.”
actions: {
// any amount of arguments, return a promise or not
addTodo(text) {
// you can directly mutate the state
this.todos.push({ text, id: this.nextId++, isFinished: false })
},
},
We can do the same thing over in our ES6 class, without any framework getting involved:
addToDo(text:string):void {
this._todos.push({text: text, isFinished: false, id: this.nextId++})
}
Going further: “set”
functions
We don’t have a need for it here, but at work, I’ve successfully used property setters to allow components to do (what looks like) simple assignments of updated values that cause side effects:
set firstName(v: string) {
this._firstname = v
// some side effect
this.nameChanged = true
}
You can decide if that’s appropriate. After years of other state managers, I’m on the fence.
Bonus round: testing
All of the framework-specific store implementations I used involve additional boilerplate for testing.
Not so with Svelte.
SvelteKit automagically plugs into Vitest, making it a breeze to test class-based stores, including the last test’s demonstration of reactivity being honored:
import { describe, it, expect } from 'vitest';
import { todoStore } from "$lib/todo/todos.svelte";
describe('TodoStore test', () => {
it('starts with no todos', () => {
expect( todoStore.todos.length ).toBe(0);
});
it('can add multiple todos', () => {
Array(10).fill(0).map( ( _, idx) => {
todoStore.addToDo(`Todo ${idx + 1}`)
})
expect(todoStore.todos.length).toBe(10)
})
// Note that our reactivity _just works_
// here in the test! There's no frameworkiness
// bleeding through.
it("recognized isFinished in filters", () => {
todoStore.filter = 'all'
expect(todoStore.todos.length).toBe(10)
todoStore.filter = 'finished'
expect(todoStore.todos.length).toBe(0)
todoStore.filter = 'unfinished'
expect(todoStore.todos.length).toBe(10)
todoStore.todos[0].isFinished = true
todoStore.todos[1].isFinished = true
todoStore.filter = 'all'
expect(todoStore.todos.length).toBe(10)
todoStore.filter = 'finished'
expect(todoStore.todos.length).toBe(2)
todoStore.filter = 'unfinished'
expect(todoStore.todos.length).toBe(8)
})
});
Conclusion
I’m pretty confident that ES6 classes make decent Svelte stores with minimal frameworkiness. At work, I’ve been using this approach for a few weeks with some fairly complicated derived state and bridges to non-reactive, third-party components, and I haven’t yet spent an afternoon tracking down why one thing won’t update.
I smile a lot, and that’s good.