📍 Article principal du cluster : Svelte 5 et SvelteKit 2 en production : guide complet 2026
Cet article fait partie du cluster Svelte 5. Pour la vue d’ensemble, lire d’abord le pilier.
Introduction
Un développeur d’une PME logistique basée à Cocody, Abidjan, nous appelle un mardi matin : son tableau de bord SvelteKit 2 affiche des nombres incohérents quand l’utilisateur change de filtre. Diagnostic en 15 minutes : il a mélangé $state, $derived et la syntaxe Svelte 4 $: dans le même composant. Ce tutoriel évite l’erreur en posant les fondations des runes — la nouvelle réactivité de Svelte 5 — avec des exemples concrets, des pièges réels rencontrés en production, et des recettes copier-coller qui marchent du premier coup.
Prérequis
- Node.js 20 LTS ou 22 LTS (vérifier
node --version) - Un projet SvelteKit 2 fonctionnel (
npm create svelte@latest mon-app, choisir SvelteKit + TypeScript) - Bases JavaScript moderne (let, const, fonctions fléchées, destructuration)
- Niveau attendu : intermédiaire
- Temps estimé : 45 minutes pour parcourir, 2 heures pour pratiquer chaque pattern
Étape 1 — $state : la valeur réactive
La rune $state remplace la déclaration let magique de Svelte 4. Toute mutation d’une variable déclarée avec $state déclenche un re-rendu des parties du DOM qui en dépendent. Les objets et tableaux passés à $state deviennent des proxies réactifs en profondeur — modifier user.email ou pousser dans items est détecté automatiquement.
<script>
let count = $state(0);
let user = $state({ name: 'Aïssatou', email: 'a@example.sn' });
let items = $state([]);
function ajouter() {
items.push({ id: Date.now(), label: `Article ${count++}` });
}
</script>
<p>Compteur : {count}</p>
<p>Email : {user.email}</p>
<ul>{#each items as i (i.id)}<li>{i.label}</li>{/each}</ul>
<button onclick={ajouter}>Ajouter</button>
Trois nuances à connaître. Premièrement, $state.frozen(...) crée une valeur réactive mais non-mutable côté Svelte (pour des structures immuables type Redux). Deuxièmement, $state.snapshot(value) retourne une copie POJO non-réactive — indispensable pour passer la valeur à console.log sans pollution, ou à une lib externe (axios, fetch body) qui n’attend pas un proxy. Troisièmement, on ne peut pas réassigner un proxy à une variable normale et garder la réactivité — l’affectation copie la valeur, pas le proxy.
Étape 2 — $derived : les calculs dérivés
La rune $derived calcule une valeur à partir d’autres valeurs réactives. Le calcul est paresseux : il ne s’exécute que quand la valeur est lue, et seulement si une dépendance a changé depuis la dernière lecture. Cela remplace la directive $: de Svelte 4 pour les dérivations pures.
<script>
let prix = $state(2500);
let quantite = $state(3);
let tva = $state(0.18);
// Forme courte : expression unique
let sousTotal = $derived(prix * quantite);
// Forme longue : pour calculs complexes ou conditionnels
let total = $derived.by(() => {
const ht = sousTotal;
const remise = ht > 10000 ? ht * 0.05 : 0;
return Math.round((ht - remise) * (1 + tva));
});
</script>
<p>Sous-total : {sousTotal} FCFA</p>
<p>Total TTC : {total} FCFA</p>
Trois pièges concrets. Premièrement, un $derived ne doit jamais avoir d’effet de bord (modifier une autre variable, appeler une API). Pour ça, c’est $effect. Deuxièmement, on ne peut pas écrire dans un $derived — la valeur est en lecture seule. Pour un état dérivé modifiable (computed setter en Vue), on utilise un $state + un $effect qui le synchronise. Troisièmement, les boucles infinies sont silencieuses : si un $derived dépend d’une variable qu’il modifie indirectement via $effect, le compilateur ne le détecte pas — toujours tester le composant avec un changement réel avant de livrer.
Étape 3 — $effect : les effets de bord
La rune $effect exécute une fonction après que le DOM a été mis à jour, et la relance dès qu’une de ses dépendances réactives change. C’est l’équivalent du useEffect de React, mais sans tableau de dépendances explicite : le compilateur détecte automatiquement les valeurs lues.
<script>
import { onMount } from 'svelte';
let panier = $state([]);
let total = $derived(panier.reduce((a, x) => a + x.prix * x.qte, 0));
// Synchronisation localStorage
$effect(() => {
localStorage.setItem('panier-itskills', JSON.stringify($state.snapshot(panier)));
});
// Effet avec nettoyage (timer, listener)
$effect(() => {
if (total > 50000) {
const id = setTimeout(() => alert('Panier élevé'), 2000);
return () => clearTimeout(id);
}
});
</script>
Le piège n°1 que nous voyons en mission : les valeurs lues après un await ne sont pas suivies. Le compilateur n’instrumente que la lecture synchrone initiale. Si on doit relancer un effet en fonction d’une valeur asynchrone, on lit la dépendance avant l’await, on la stocke dans une variable locale, et on l’utilise après. Le piège n°2 : $effect n’est utilisable que dans le corps d’un composant ou d’un autre effet — pas dans un module top-level. Pour de la logique partagée, on encapsule dans une fonction createXxx() appelée depuis le composant.
Variantes : $effect.pre(...) s’exécute avant la mise à jour du DOM (utile pour mesurer la position du scroll avant changement). $effect.root(...) crée un effet en dehors d’un composant (cas avancé, à n’utiliser que si on connaît bien le scope).
Étape 4 — $props et $bindable
La rune $props() remplace les export let de Svelte 4. Elle retourne un objet contenant toutes les props passées au composant, avec destructuration et valeurs par défaut.
<!-- Carte.svelte -->
<script lang="ts">
let {
titre,
description = 'Sans description',
image,
onclick = () => {}
}: {
titre: string;
description?: string;
image?: string;
onclick?: () => void;
} = $props();
</script>
<article onclick={onclick} role="button">
{#if image}<img src={image} alt="" />{/if}
<h3>{titre}</h3>
<p>{description}</p>
</article>
Pour le pattern formulaire contrôlé (parent qui veut bind:value sur l’enfant), on déclare la prop comme $bindable() :
<!-- Champ.svelte -->
<script>
let { value = $bindable('') } = $props();
</script>
<input bind:value />
<!-- Parent.svelte -->
<script>
let email = $state('');
</script>
<Champ bind:value={email} />
<p>Saisi : {email}</p>
La règle de design : $bindable est explicite et limité. Si la majorité des props doivent être bindables, on pose probablement la mauvaise frontière de composant — préférer un état partagé via context ou un store dédié.
Étape 5 — Patterns réels en production
Compteur partagé entre composants. On encapsule l’état dans une fonction qui retourne un objet contenant $state et des actions.
// stores/panier.svelte.ts
export function createPanier() {
let items = $state<Array<{ id: string; prix: number; qte: number }>>([]);
return {
get items() { return items; },
ajouter(item: { id: string; prix: number; qte: number }) {
const existant = items.find(x => x.id === item.id);
if (existant) existant.qte += item.qte;
else items.push(item);
},
vider() { items.length = 0; }
};
}
// app.svelte ou +layout.svelte
import { createPanier } from './stores/panier.svelte';
import { setContext } from 'svelte';
setContext('panier', createPanier());
Côté enfant : const panier = getContext('panier') puis panier.ajouter({...}). Cette factory + context est l’équivalent idiomatique d’un store global Pinia / Zustand, sans dépendance externe.
Synchronisation API + état local. On combine $state, $effect et un débounce :
let recherche = $state('');
let resultats = $state([]);
let chargement = $state(false);
let timer: number | undefined;
$effect(() => {
const terme = recherche.trim();
clearTimeout(timer);
if (!terme) { resultats = []; return; }
chargement = true;
timer = setTimeout(async () => {
const r = await fetch(`/api/clients?q=${encodeURIComponent(terme)}`);
resultats = await r.json();
chargement = false;
}, 300);
});
Étape 6 — Vérification et tests
Pour valider qu’un composant à runes se comporte comme prévu, on lance les tests unitaires :
npm i -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
npx vitest run
Test type :
// Carte.test.ts
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Carte from './Carte.svelte';
test('affiche le titre et la description par défaut', () => {
render(Carte, { titre: 'Test' });
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.getByText('Sans description')).toBeInTheDocument();
});
Output attendu : 1 passed (1). Si le test échoue avec « Cannot read properties of undefined (reading ‘titre’) », c’est que $props() n’a pas été déstructuré correctement.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| « Cannot use rune in non-runes mode » | Fichier .svelte sans rune mais avec <svelte:options runes={false} /> en parent |
Activer le mode runes globalement dans svelte.config.js via compilerOptions.runes: true |
| Console.log affiche un proxy bizarre | État réactif loggé directement | Utiliser console.log($state.snapshot(value)) |
$derived qui ne se met pas à jour |
Lecture après await ou dans un setTimeout |
Lire les dépendances de manière synchrone dans la fonction |
| Boucle infinie de re-rendu | $effect qui modifie une dépendance qu’il lit |
Conditionner la mutation, ou utiliser untrack() |
| Composant qui se re-render trop | Tableau ou objet recréé à chaque dérivation | Mettre la création dans $state, ne dériver que les transformations |
Adaptation au contexte ouest-africain
Les runes apportent un avantage pratique pour les apps qui doivent persister l’état hors-ligne (boutiques de quartier à Pikine ou Treichville où la connexion coupe en milieu de panier). Le pattern $state + $effect qui synchronise dans localStorage ou IndexedDB tient en cinq lignes, et garantit qu’un panier en cours n’est jamais perdu. Pour les paiements Wave ou Orange Money, la même logique s’applique : on garde l’état du paiement en local, on retente si l’API échoue, et on ne marque l’achat comme finalisé que sur réception du callback serveur — toute la machinerie tient dans deux $effect bien placés.
Tutoriels frères
Pour aller plus loin
- 🔝 Retour au pilier : Svelte 5 et SvelteKit 2 en production : guide complet 2026
- Documentation officielle : svelte.dev — What are runes
- Suivant dans le cluster : Form actions et validation Zod
FAQ
Peut-on utiliser des runes dans un composant Svelte 4 ?
Non, le mode runes s’active par fichier et tout le composant doit suivre la nouvelle syntaxe. On ne mixe pas $: et $derived dans le même fichier.
Les runes fonctionnent-elles côté serveur (SSR) ?
Oui. Pendant le rendu serveur, les runes s’évaluent une fois pour produire le HTML initial, sans réactivité (logique). L’hydratation côté client active la réactivité.
Comment migrer un store writable existant vers les runes ?
Remplacer writable(0) par $state(0) dans une factory. Les abonnés store.subscribe(...) deviennent des $effect(() => { ... store.value ... }). Le helper $store dans les templates n’a plus de sens, on lit directement la valeur.
Quel impact sur la taille du bundle ?
Marginal. Les runes ajoutent quelques fonctions d’instrumentation, compensées par la suppression de la machinerie $:. En pratique, l’écart est inférieur à 1 Ko gzippé.
Patterns avancés : context, untrack, store legacy
Trois patterns reviennent constamment sur les projets de production que nous accompagnons. Ils méritent un traitement détaillé pour éviter les pièges qui n’apparaissent qu’après plusieurs semaines en charge réelle.
Context API + factory + runes. Pour partager un état entre composants éloignés sans drilling de props, on combine setContext/getContext avec une factory qui crée un objet contenant des runes. Cette approche remplace les stores Svelte 4 dans la plupart des cas. La factory garantit qu’à chaque montage de l’arbre on récupère une nouvelle instance — utile pour les tests et le SSR où chaque requête doit avoir son propre état isolé.
// stores/auth.svelte.ts
import { setContext, getContext } from 'svelte';
const KEY = Symbol('auth');
export function createAuth() {
let user = $state<{ id: string; nom: string } | null>(null);
let chargement = $state(false);
return {
get user() { return user; },
get chargement() { return chargement; },
async connecter(email: string, mdp: string) {
chargement = true;
const r = await fetch('/api/auth/login', { method:'POST', body: JSON.stringify({email,mdp}) });
user = r.ok ? await r.json() : null;
chargement = false;
return user !== null;
},
deconnecter() { user = null; }
};
}
export const setAuth = (a: ReturnType<typeof createAuth>) => setContext(KEY, a);
export const getAuth = () => getContext<ReturnType<typeof createAuth>>(KEY);
Côté +layout.svelte racine on appelle setAuth(createAuth()) ; côté composants enfants const auth = getAuth() puis auth.user.nom. La réactivité fonctionne sans $: ni subscribe.
untrack pour suspendre la dépendance. Dans un $effect, on veut parfois lire une valeur sans en faire une dépendance. La fonction untrack de svelte sert exactement à ça :
import { untrack } from 'svelte';
let recherche = $state('');
let dernierResultat = $state<any>(null);
$effect(() => {
const terme = recherche; // dépendance suivie
fetch(`/api/search?q=${terme}`).then(r => r.json()).then(res => {
untrack(() => { dernierResultat = res; }); // ne pas créer de boucle
});
});
Migration store legacy → runes par couche. Pour un projet qui contient encore des writable partagés, on garde le store mais on enveloppe son utilisation dans un wrapper rune. Cela permet de migrer composant par composant sans tout casser :
// wrappers/legacy.svelte.ts
import { writable, type Writable } from 'svelte/store';
import { onDestroy } from 'svelte';
export function fromStore<T>(store: Writable<T>) {
let value = $state<T | undefined>(undefined);
const unsub = store.subscribe(v => { value = v; });
onDestroy(unsub);
return {
get value() { return value; },
set(v: T) { store.set(v); }
};
}
Mesurer l’impact sur les performances
Avant et après chaque optimisation à base de runes, on mesure. Les outils gratuits suffisent dans 95 % des cas :
- Lighthouse en mode « Slow 4G + 4× CPU throttling » simule une connexion réelle ouest-africaine. Lancer trois fois, prendre la médiane.
- Performance API du navigateur :
performance.mark('debut')etperformance.measure('total','debut')autour de la mutation, pour mesurer le coût d’une dérivation lourde. - Vite bundle visualizer (
npm i -D rollup-plugin-visualizer) pour vérifier qu’aucune dépendance React/Vue ne s’est glissée.
Sur un projet client à Cocody, le passage de stores writable + $: à runes pures a réduit la taille du bundle gzip de 14 Ko (8 % du total) et le temps de premier rendu interactif de 220 ms — non négligeable sur 4G saturée.