Gestion d'état dans Svelte 5 avec les Runes
Gestion d état Svelte 5 avec les runes: utilisez $state, $derived et $effect, partagez l état entre composants et évitez les fuites SSR dans SvelteKit.
Les runes sont des directives de compilation — et non des imports à l’exécution — qui rendent la réactivité explicite et portable : $state déclare une cellule réactive, $derived calcule une valeur à partir de celle-ci, et $effect réagit aux changements — remplaçant ensemble les déclarations implicites $: de Svelte 4 et les stores accessibles en écriture dans ces trois rôles. La documentation Svelte décrit les runes comme des mots-clés reconnus par le compilateur, ce qui explique pourquoi vous ne pouvez pas les aliaser, les importer ou les appeler de manière conditionnelle. Connecter $state à un seul composant est la partie facile. La partie difficile — celle qui échoue en production — consiste à structurer un état partagé entre plusieurs composants sans perdre la réactivité, et à délimiter correctement cet état lorsque SvelteKit effectue un rendu côté serveur.
Cet article traite la gestion d’état comme une colonne vertébrale : l’état local avec $state, l’état calculé avec $derived, les effets de bord avec $effect, puis la partie que la plupart des guides omettent — le partage d’état entre fichiers via des modules .svelte.ts, sa délimitation par requête pour le SSR, et une règle de décision pour choisir le bon outil à chaque étape. Le fil conducteur : la réactivité dans Svelte 5 est opt-in par valeur, et presque tous les pièges découlent de l’endroit où le proxy s’arrête — les instances de classe, les bindings déstructurés et les exports let ESM se situent tous en dehors de la frontière du proxy. Les versions référencées ici ciblent Svelte 5.x et SvelteKit 2.x.
Points clés à retenir
- Toute valeur pouvant être exprimée comme une fonction pure de l’état existant appartient à
$derived, et non à$effect; utiliser$effectpour synchroniser un état à partir d’un autre est l’utilisation abusive la plus courante de cette rune. - Exporter un
$stateréassignable depuis un module.svelte.tsdéclenche l’erreur de compilationstate_invalid_export; les deux corrections approuvées consistent à exporter une fonction retournant l’état, ou à exporter un objetconstou une instance de classe et à muter ses propriétés. - Le pattern recommandé pour un état client global à l’application est une classe avec des champs
$stateexportée en tant qu’instanceconst— le bindingconstn’est jamais réassigné, ce qui fait disparaître le problèmestate_invalid_export. - Un module
.svelte.tsqui déclare un$statede premier niveau crée une instance partagée par processus serveur, ce qui entraîne des fuites d’état entre utilisateurs dans le SSR SvelteKit ; délimitez l’état par requête en le retournant depuisloadet en le partageant viasetContext/getContext. - Déstructurer un objet
$statecapture la valeur au moment de la déstructuration, et non un binding réactif — lisez à travers le proxy ou passez un getter pour préserver la réactivité.
Qu’est-ce que $state et comment fonctionne l’état réactif local ?
$state déclare une cellule réactive dont les lectures enregistrent des dépendances et dont les écritures planifient des mises à jour ; pour les primitives, vous assignez et réassignez normalement, et pour les objets et les tableaux, Svelte retourne un proxy profond permettant de suivre les mutations directes. D’après la documentation de $state, passer un objet ou un tableau simple le rend profondément réactif — vous le mutez en place et l’interface utilisateur dépendante se met à jour.
<script lang="ts">
let count = $state(0);
let todos = $state([{ id: 1, text: 'Learn runes', done: false }]);
function addTodo() {
todos.push({ id: Date.now(), text: 'New todo', done: false }); // suivi
}
function toggle(id: number) {
const todo = todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done; // l'assignation de propriété est suivie
}
</script>
<button onclick={() => count++}>Clicked {count} times</button>
Sur un objet ou un tableau $state profondément réactif, push() et l’assignation directe de propriété fonctionnent tous deux, car le proxy intercepte les mutations à n’importe quelle profondeur. La documentation note une limite : la proxification s’arrête aux instances de classe. Un objet ou un tableau simple devient profondément réactif ; une instance de classe ne l’est pas, à moins que ses champs ne soient eux-mêmes déclarés avec $state.
Pour des données volumineuses et plates que vous remplacez intégralement plutôt que de les muter, utilisez $state.raw, qui ne suit que la réassignation — et non la mutation.
<script lang="ts">
let payload = $state.raw(largeApiResponse);
// payload.foo = 'x'; // pas de mise à jour — la mutation n'est pas suivie
payload = { ...payload, foo: 'x' }; // mise à jour déclenchée — la réassignation est suivie
</script>
$state.raw évite la création d’un proxy, ce qui supprime la surcharge liée aux descripteurs par propriété lors du proxying profond de grands objets plats. Utilisez-le lorsque les valeurs sont volumineuses, effectivement immuables et remplacées en bloc — réponses JSON parsées, blobs de configuration, tables de correspondance. Dans les autres cas, utilisez $state par défaut.
Comment $derived et $derived.by gèrent-ils l’état calculé ?
Discover how at OpenReplay.com.
$derived déclare une valeur calculée à partir d’un état réactif qui se met à jour automatiquement lorsque ses dépendances changent, remplaçant les déclarations réactives $: de Svelte 4. D’après la documentation de $derived, il est recalculé à partir de ses dépendances lors de la prochaine lecture après qu’une d’entre elles a changé. Depuis Svelte 5.25, un $derived non-const peut également être temporairement réassigné pour remplacer sa valeur — utile pour une interface utilisateur optimiste — après quoi il revient à sa valeur calculée lorsqu’une dépendance change à nouveau.
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
</script>
Pour les calculs nécessitant des boucles, des conditions ou plusieurs instructions, utilisez $derived.by, qui prend une fonction plutôt qu’une expression :
<script lang="ts">
let items = $state([{ price: 10, quantity: 2 }]);
let summary = $derived.by(() => {
let total = 0;
let count = 0;
for (const item of items) {
total += item.price * item.quantity;
count += item.quantity;
}
return { total, count };
});
</script>
La règle qui prévient la plupart des bugs de réactivité : toute valeur pouvant être exprimée comme une fonction pure de l’état existant appartient à $derived, et non à $effect. Utiliser $effect pour synchroniser un état à partir d’un autre introduit un cycle réactif redondant et constitue l’utilisation abusive la plus courante de cette rune. Si vous vous retrouvez à écrire un effet qui définit un état à partir d’un autre état, remplacez-le par $derived.
Quand devez-vous utiliser $effect ?
$effect est réservé aux effets de bord uniquement — abonnements, journalisation, manipulation manuelle du DOM et persistance — jamais pour dériver des valeurs. D’après la documentation de $effect, les effets s’exécutent après la mise à jour du DOM, suivent automatiquement les valeurs réactives lues en leur sein, se réexécutent lorsque ces valeurs changent, et ne s’exécutent que dans le navigateur — jamais lors du SSR. Il n’y a pas de tableau de dépendances ; Svelte détecte les dépendances à partir de ce que vous lisez.
<script lang="ts">
let count = $state(0);
$effect(() => {
const id = setInterval(() => console.log('tick', count), 1000);
return () => clearInterval(id); // le nettoyage s'exécute avant la réexécution et à la destruction
});
</script>
La fonction retournée est un callback de nettoyage — elle s’exécute avant la réexécution de l’effet et lorsque le composant est détruit, c’est là que vous démontez les intervalles, les écouteurs et les abonnements. Étant donné que les effets ignorent entièrement le SSR, ne comptez pas sur eux pour produire une sortie rendue côté serveur ; ce travail appartient aux fonctions load ou à $derived.
L’unique utilisation abusive à mémoriser : n’utilisez pas $effect pour copier un état. Écrire $effect(() => { fullName = `${first} ${last}` }) crée une boucle de rétroécriture là où un $derived serait à la fois correct et plus simple :
<script lang="ts">
let first = $state('Ada');
let last = $state('Lovelace');
let fullName = $derived(`${first} ${last}`); // correct — aucun effet nécessaire
</script>
Comment partager un état entre composants dans Svelte 5 ?
Pour partager un état réactif entre fichiers, placez-le dans un module .svelte.ts — mais vous ne pouvez pas exporter directement un binding $state réassignable. Le pattern intuitif échoue :
// src/lib/counter.svelte.ts
export let count = $state(0);
export function increment() {
count += 1;
}
Exporter un binding $state réassignable depuis un module de cette manière déclenche l’erreur de compilation documentée state_invalid_export, avec le message textuel : “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.” Le déclencheur est la réassignation (count += 1), et non l’import lui-même — l’erreur se produit parce que le $state exporté est réassigné, ce qu’un export let ESM ne peut pas faire de manière sécurisée à travers la frontière du module. Il existe deux corrections approuvées.
Correction 1 — exporter un objet const et muter ses propriétés. Le binding n’est jamais réassigné ; seules ses propriétés changent, de sorte que chaque importateur lit le même proxy.
// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
counter.count += 1; // mutation de propriété, pas de rebinding
}
Correction 2 — garder l’état local au fichier et exporter des getters. Cela maintient la cellule privée et empêche la réassignation externe.
// src/lib/counter.svelte.ts
let count = $state(0);
export function getCount() {
return count;
}
export function increment() {
count += 1;
}
Le pattern recommandé pour un état client global à l’application est la version la plus propre de la Correction 1 : une classe avec des champs $state exportée en tant qu’instance const. Le binding const n’est jamais réassigné, ce qui fait disparaître le problème state_invalid_export, et chaque composant important l’instance lit depuis le même proxy réactif.
// src/lib/todo-store.svelte.ts
class Todo {
done = $state(false);
text = $state('');
constructor(text: string) {
this.text = text;
}
}
class TodoStore {
items = $state<Todo[]>([]);
filter = $state<'all' | 'active' | 'done'>('all');
get visible(): Todo[] {
if (this.filter === 'all') return this.items;
const wantDone = this.filter === 'done';
return this.items.filter((t) => t.done === wantDone);
}
add(text: string) {
this.items.push(new Todo(text));
}
remove(todo: Todo) {
this.items = this.items.filter((t) => t !== todo);
}
}
export const todoStore = new TodoStore();
Tout composant qui importe todoStore lit et mute la même instance réactive — sans câblage de contexte ni boilerplate d’abonnement. Notez le paramètre de type $state<Todo[]>([]) : TypeScript infère les types à partir des initialiseurs, mais vous passez un paramètre explicite pour les tableaux vides, les objets vides ou les unions plus larges que la valeur initiale. Ce pattern comporte une mise en garde importante pour le SSR, abordée ci-dessous.
Comment $state se comporte-t-il à l’intérieur des classes ?
$state fonctionne comme un champ de classe, et le compilateur transforme chaque champ en une paire d’accesseurs get/set sur le prototype, adossée à un signal privé. La documentation sur les classes $state décrit cette transformation, et elle a deux conséquences à connaître : étant donné que les accesseurs résident sur le prototype plutôt que sur l’instance, Object.keys(instance) ne liste pas les champs réactifs, et la décomposition { ...instance } les omet.
class Todo {
done = $state(false);
text = $state('');
constructor(text: string) {
this.text = text;
}
toggle() {
this.done = !this.done;
}
}
Le piège qui surprend tout le monde est le binding de this dans les gestionnaires d’événements. Passer une référence de méthode la détache de l’instance, et la documentation couvre ce cas directement :
<!-- this === le <button>, pas l'instance Todo — incorrect -->
<button onclick={todo.toggle}>toggle</button>
<!-- this === todo — correct -->
<button onclick={() => todo.toggle()}>toggle</button>
Étant donné que les lectures et les écritures traversent les accesseurs du prototype, la méthode a besoin du bon this. Deux corrections permettent de le maintenir lié : encapsuler l’appel dans une fonction fléchée au site d’appel (() => todo.toggle()), ou définir la méthode comme un champ de fonction fléchée qui ferme sur this :
class Todo {
done = $state(false);
toggle = () => {
this.done = !this.done; // champ fléché — `this` est lié de façon permanente
};
}
Utilisez la forme de champ fléché lorsque vous avez l’intention de passer la méthode comme référence ; utilisez une méthode normale lorsque vous l’appelez toujours sous la forme todo.toggle().
Quels sont les principaux pièges de réactivité avec les runes ?
La plupart des bugs liés aux runes remontent à une règle unique : la réactivité est opt-in par valeur, et le proxy s’arrête aux instances de classe, aux bindings déstructurés et aux exports ESM. Trois pièges expliquent la majorité des pertes de réactivité.
La déstructuration capture une valeur, pas un binding. Déstructurer un objet $state lit la valeur au moment de la déstructuration ; cela ne crée pas de référence réactive.
const user = $state({ name: 'Ada', age: 36 });
const { name } = user;
user.name = 'Grace'; // user.name se met à jour
console.log(name); // toujours 'Ada' — capturé au moment de la déstructuration
Pour préserver la réactivité, lisez à travers le proxy d’origine (user.name) ou passez une fonction getter (() => user.name) qui ferme sur le proxy et relit à chaque appel. C’est la correction à adopter chaque fois qu’une valeur « cesse de se mettre à jour » après que vous l’avez refactorisée dans une variable.
Les Map, Set, Date et URL natifs sont des instances de classe, donc le proxy s’arrête à eux. Utilisez les équivalents réactifs de svelte/reactivity, qui suivent les lectures sur .size, .get(), .has() et l’itération : SvelteMap, SvelteSet, SvelteDate, SvelteURL et SvelteURLSearchParams.
import { SvelteMap } from 'svelte/reactivity';
const cache = new SvelteMap<string, number>();
cache.set('a', 1); // réactif
La documentation note une mise en garde : les valeurs stockées à l’intérieur d’une Map ou d’un Set réactif ne sont pas rendues profondément réactives. Si vous stockez un objet simple dans une SvelteMap et attendez de muter ses propriétés de manière réactive, encapsulez d’abord cet objet avec $state.
Les proxies ne peuvent pas traverser certaines frontières d’API. structuredClone, postMessage et certains sérialiseurs rejettent les proxies. Utilisez $state.snapshot pour prendre une copie statique ordinaire à la frontière :
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify($state.snapshot(user)),
});
Utilisez $state.snapshot uniquement à ces frontières, pas dans l’ensemble de votre code — le proxy est ce que vous souhaitez partout ailleurs.
Quand utiliser un état global au module plutôt que le contexte Svelte ?
L’état global au module est sûr pour les applications client uniquement, mais dangereux pour le SSR ; l’état délimité par requête appartient à une fonction load et est partagé via l’API de contexte de Svelte. Un module .svelte.ts qui déclare un $state de premier niveau crée une instance partagée unique par processus serveur. Côté client, c’est exactement ce que vous souhaitez. Dans le SSR SvelteKit, c’est un risque de fuite entre requêtes : la documentation de gestion d’état SvelteKit indique que les serveurs sont persistants et partagés entre utilisateurs, que vous ne devez pas stocker de données par utilisateur dans des variables de module partagées, et donne l’exemple canonique du secret d’un utilisateur qui se retrouve dans le rendu d’un autre.
La fuite ressemble à ceci — un store de module muté lors d’un rendu serveur est visible pour la requête suivante atteignant le même processus :
// src/lib/user.svelte.ts — DANGEREUX en SSR
export const currentUser = $state({ name: '' });
// +page.server.ts — définit un état partagé lors du SSR
import { currentUser } from '$lib/user.svelte';
export function load({ locals }) {
currentUser.name = locals.user.name; // fuite entre requêtes
}
La correction canonique consiste à retourner les données par requête depuis load et à les partager via setContext/getContext, ce qui délimite la valeur à un seul arbre de composants — et donc à une seule requête — plutôt qu’à un module à l’échelle du processus.
// +layout.server.ts
export function load({ locals }) {
return { user: locals.user }; // données par requête
}
<!-- +layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
let { data } = $props();
setContext('user', data.user); // délimité à l'arbre de composants de cette requête
</script>
Dans les applications SvelteKit en production, un état de niveau module mal délimité apparaît dans les replays de session sous la forme d’un utilisateur voyant brièvement les données d’un autre utilisateur ou des valeurs obsolètes au chargement initial — le symptôme visible d’un état qui aurait dû être délimité par requête en retournant des données par requête depuis load et en les partageant via le contexte, plutôt qu’en les conservant dans un état global au module. Les replays de session de ces implémentations font remonter le bug car ils capturent le DOM initial rendu côté serveur, et non uniquement l’état post-hydratation.
La règle de décision qui relie toute la colonne vertébrale :
Utilisez
$statelocal au composant pour les valeurs appartenant à un seul composant ;$derivedpour tout ce qui est calculé à partir de cet état ; un store de classe au niveau du module (export const store = new Store()) pour l’état client global à l’application ; et des données par requête retournées depuisloadet partagées viasetContext/getContextpour l’état délimité par requête ou SSR qui ne doit pas fuiter entre utilisateurs.
Le câblage des props — $props, $bindable et l’assistant de débogage $inspect — se situe en dehors de cette colonne vertébrale de gestion d’état ; traitez-les comme des outils d’interface de composant et de débogage plutôt que comme des conteneurs d’état.
Migration des stores Svelte 4 vers les runes
La plupart des patterns de réactivité Svelte 4 se mappent directement sur les runes, y compris le cas d’état partagé que les stores accessibles en écriture géraient auparavant. Le tableau ci-dessous couvre les traductions que les développeurs de niveau intermédiaire rencontrent le plus souvent ; le guide de migration Svelte 5 couvre l’ensemble des cas.
| Svelte 4 | Runes Svelte 5 |
|---|---|
let count = 0; | let count = $state(0); |
$: doubled = count * 2; | let doubled = $derived(count * 2); |
$: console.log(count); | $effect(() => console.log(count)); |
export let name = 'world'; | let { name = 'world' } = $props(); |
const count = writable(0); $count++ | let count = $state(0); count++ |
Store writable partagé entre composants | Store de classe exporté en tant que export const store = new Store() |
| Store abonné dans le layout SSR pour les données par utilisateur | load retourne les données → setContext/getContext |
Les deux dernières lignes sont celles que la plupart des guides omettent : un store accessible en écriture partagé dans une application devient une instance de classe exportée en tant que const, et un store par utilisateur dans un contexte SSR devient des données par requête retournées depuis load et transmises via le contexte.
Les runes rendent le modèle de réactivité explicite, mais l’avantage réside dans l’architecture : gardez l’état aussi local que possible, promouvez-le vers un store de classe au niveau du module uniquement lorsque plusieurs composants le partagent réellement, et délimitez-le via le contexte dès que le SSR entre en jeu. Auditez vos modules .svelte.ts pour détecter les $state de premier niveau qui participent au rendu serveur — cette vérification unique permet d’identifier la classe de bugs d’état la plus critique dans SvelteKit.
FAQ
Quelle est la différence entre $state.raw et $state dans Svelte 5 ?
$state retourne un proxy profond qui suit les mutations à n'importe quelle profondeur, de sorte que push() et l'assignation de propriété déclenchent des mises à jour ; $state.raw évite la création de proxy et ne suit que la réassignation, ce qui signifie que la mutation en place est ignorée et que vous devez remplacer la valeur intégralement pour déclencher une mise à jour. Utilisez $state.raw pour des données volumineuses, effectivement immuables et remplacées en bloc, telles que des réponses JSON parsées, des blobs de configuration ou des tables de correspondance, lorsque l'élimination de la surcharge du proxy par propriété est importante. Utilisez $state par défaut dans les autres cas.
Pourquoi ma valeur $state cesse-t-elle de se mettre à jour après que je l'ai déstructurée ?
Déstructurer un objet $state lit la valeur au moment de la déstructuration et crée une variable ordinaire non réactive ; cela ne crée pas de binding réactif vers le proxy. Après const name = user.name, muter ultérieurement user.name met à jour le proxy mais laisse le name déstructuré figé à sa valeur d'origine. Pour conserver la réactivité, lisez à travers le proxy d'origine avec user.name au point d'utilisation, ou passez une fonction getter telle que () => user.name qui ferme sur le proxy et relit à chaque appel.
Les callbacks $effect s'exécutent-ils lors du SSR dans SvelteKit ?
Non. Les callbacks $effect ne s'exécutent que dans le navigateur et jamais lors du rendu côté serveur. Les effets s'exécutent après la mise à jour du DOM et se réexécutent lorsque les valeurs réactives qu'ils lisent changent, ce qui signifie qu'ils ne peuvent pas contribuer au HTML rendu côté serveur. Ne comptez pas sur $effect pour produire une sortie lors du SSR ; placez ce travail dans une fonction load ou dans $derived. Les effets sont réservés aux effets de bord côté navigateur uniquement, tels que les abonnements, la journalisation, la manipulation manuelle du DOM, les intervalles et la persistance, avec un callback de nettoyage optionnel retourné.
Les valeurs stockées dans une SvelteMap ou une SvelteSet sont-elles profondément réactives ?
Non. SvelteMap et SvelteSet de svelte/reactivity suivent les lectures sur .size, .get(), .has() et l'itération, et réagissent aux entrées ajoutées ou supprimées, mais les valeurs stockées en leur sein ne sont pas rendues profondément réactives. Si vous stockez un objet simple dans une SvelteMap et attendez que la mutation de ses propriétés déclenche des mises à jour, encapsulez d'abord cet objet avec $state afin que l'objet interne devienne un proxy réactif. La collection réactive suit la structure, pas l'état interne des valeurs stockées arbitraires.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k