I kept hearing good things about the new Svelte 5 features and how amazing they are, and I wanted to actually use them before forming my opinions. So, as part of an effort in my team to evaluate Svelte for future projects, I rebuilt a small memory card game with Svelte 5 and took notes on what I found interesting. This post is those notes. (The full code for Snack Match is on GitHub if you want to follow along.)
So what is Snack Match?
Snack Match is a classic memory card game. Sixteen cards in a 4x4 grid, flip two cards at a time, find all the matches. The game tracks your moves and time, and you get confetti when you win (because of course you do). Simple enough. But even a game this small needs reactive state for the cards and timer, derived values to know when the game is complete, side effects for cleanup, and component composition to keep things organised. It turns out to be a surprisingly complete framework exercise. So, what did I learn?
A quick tour of runes
Svelte 5 introduces “runes”, explicit reactivity primitives that replace the old compiler-magic approach. In earlier versions of Svelte, you’d declare a variable with let and the compiler would make it reactive implicitly. It worked, but it was hard to tell which variables were reactive versus just regular values, and reactivity outside components was awkward. Runes make the intent explicit. If you’re coming from React, you’ll see echoes of useState, useMemo, and useEffect, but the ergonomics are noticeably different.
So what does a rune actually look like? All the game’s state lives in a .svelte.ts file, with the core state declared using $state.
Note: .svelte.ts is a regular TypeScript file the Svelte compiler processes. This is how you can use runes outside of component files.
let cards = $state<Card[]>(createShuffledDeck());
let flippedCards = $state<number[]>([]);
let moves = $state(0);
let isGameStarted = $state(false);
let timerSeconds = $state(0);
let isProcessing = $state(false); // lock out clicks during match check
I love it because it just feels so clean. There are no useState tuples or setter functions. You declare state and update it with plain assignment. When a player completes a move:
moves += 1;
That’s it! In React, you’d write setMoves(prev => prev + 1) or setMoves(moves + 1) (and then worry about whether you need the functional form to avoid stale closures). In Svelte, you just increment the variable. The compiler handles making it reactive under the hood. It just reads like regular JavaScript, which allows me to focus more on the actual logic.
Now, some state isn’t stored directly but rather computed from other state. $derived handles that. For example, knowing whether the game is complete is a function of whether all cards have been matched:
const isGameComplete = $derived(checkGameComplete(cards));
Where checkGameComplete is simply:
function checkGameComplete(cards: Card[]): boolean {
return cards.every((card) => card.isMatched);
}
Svelte automatically tracks that isGameComplete depends on cards. Whenever a card gets matched, Svelte just knows to recalculate. You don’t have to worry about maintaining dependency arrays, or remembering to add something to a useMemo list, it just works.
The last rune to mention is $effect, which handles side effects. When the game completes, the timer needs to stop:
$effect(() => {
if (isGameComplete && timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
});
This benefits from the same automatic dependency tracking as $derived. If you’re coming from React’s useEffect, the core concept is the same: run code in response to state changes, with optional cleanup. But what Svelte gives you is automatic tracking instead of those manual dependency arrays, which eliminates an entire category of bugs. Admittedly this is at the cost of needing to be mindful about what you read inside the effect (since everything you access gets tracked), but it still feels like a great developer experience.
The .svelte.ts getter pattern
So, what is this .svelte.ts getter pattern and how does it help us here? The game logic lives outside any component, in snack-match.svelte.ts. It’s exported as a factory function that creates a game instance. The tricky part is that if you return reactive state as plain properties ({ cards, moves, ... }), the consuming component gets a snapshot of the values at call time, not live reactive references. In practice, this means your UI just… doesn’t update. I spent a while staring at a frozen game board before I figured out what was going on.
The solution is getters:
export function createSnackMatchGame() {
let cards = $state<Card[]>(createShuffledDeck());
let moves = $state(0);
const isGameComplete = $derived(checkGameComplete(cards));
// ... more state, effects, methods
return {
get cards() { return cards; },
get moves() { return moves; },
get isGameComplete() { return isGameComplete; },
get timerSeconds() { return timerSeconds; },
// ...
flipCard,
resetGame
};
}
Each property access runs the getter, which reads the underlying $state variable, and Svelte tracks that read. So when the component’s template accesses game.cards, the getter fires, Svelte sees the dependency, and the template updates when cards changes.
This wasn’t immediately obvious from the documentation I was reading at the time, but once it clicked, the pattern felt clean. It’s essentially the same idea as React’s custom hooks, but the reactive state lives in a module rather than being tied to a component’s lifecycle.
How this compares to React
I’ve written a lot of React and still like it for many things, but the cognitive load difference here is real.
In React, you’re constantly managing the ceremony around reactivity. useState gives you a setter function you have to call correctly. useMemo requires a dependency array you have to maintain. useEffect’s dependency array is a perpetual source of subtle bugs. In Svelte 5, you just declare state and use it. The framework handles the wiring. The mental shift is from “how do I tell React about this dependency?” to “just use the value and Svelte figures it out”, which feels somewhat more freeing.
That said, Svelte’s approach has its own sharp edges. You can’t destructure $state values and keep reactivity, which will trip you up if you’re coming from React where destructuring hook returns is second nature. The getter pattern I described above isn’t obvious at all, it’s the kind of thing you only learn by hitting the wall.
Different trade-offs, perhaps no clear winner (depending on who you ask). But having built something with Svelte 5 now, I am genuinely coming around to it.
Final thoughts
Building Snack Match was a good way to develop intuition for Svelte 5. The runes system is genuinely pleasant once you internalise the patterns. If you’re evaluating Svelte for your team, I’d recommend building something small with at least some derived state and a side effect or two. The concepts click faster with hands-on experience than with docs alone.
If you’re going through a similar evaluation and want to compare notes, I’d be happy to chat!
