Le contexte

Après la migration de baccalaureat.sn vers Symfony + Turbo (racontée dans un article précédent), l'architecture est devenue : pages Twig rendues serveur, et des îlots React pour les zones interactives — le lecteur PDF, le quiz, l'assistant IA. Chaque îlot embarque son store Redux.

Le symptôme

L'utilisateur se connecte sur la page d'accueil : l'îlot d'authentification affiche bien son profil. Il navigue vers une annale... et le lecteur PDF le traite en visiteur anonyme : contenus premium verrouillés, favoris absents. Retour à l'accueil : de nouveau connecté. Chaque page semblait avoir sa propre opinion sur qui était connecté.

La cause : un store par cycle de vie de page

L'explication tient en une phrase qu'il faut avoir intégrée : un store Redux vit dans la mémoire JavaScript de la page, et Turbo remplace la page. Même si Turbo évite le rechargement complet du navigateur, chaque navigation remplace le <body> et remonte les îlots React de la nouvelle page — qui recréent leur store à l'état initial.

Page A (accueil)                    Page B (annale)
┌─────────────────────┐   Turbo    ┌─────────────────────┐
│ îlot Auth           │  ───────►  │ îlot Lecteur PDF    │
│ store #1            │  visite    │ store #2 (NEUF)     │
│ isLoggedIn: true    │            │ isLoggedIn: false ← │
└─────────────────────┘            └─────────────────────┘

Dans un SPA classique, le store est créé une fois et survit à toutes les « navigations » (qui ne sont que des changements de route côté client). Avec Turbo, ce postulat tombe. Mon code venait du monde SPA et supposait un store immortel.

La fausse piste : persister tout le store

Premier réflexe : redux-persist, pour sauvegarder et réhydrater le store entier à chaque page. J'ai reculé rapidement — persister l'intégralité de l'état (listes d'épreuves, filtres, cache PDF) entre des pages qui n'ont pas les mêmes îlots, c'est synchroniser de force des mondes qui n'ont rien à se dire, avec les bugs de réhydratation en prime.

La solution : la source de vérité hors du store

La vraie leçon d'architecture : l'authentification ne doit pas « vivre » dans Redux quand les pages sont éphémères. Le JWT vit dans localStorage — qui, lui, survit aux navigations — et chaque îlot s'hydrate depuis cette source au montage :

// auth/bootstrap.js — partagé par tous les entry points
export function readAuthFromStorage() {
    const token = localStorage.getItem('token');
    const user  = localStorage.getItem('user');

    if (!token || !user) {
        return { isLoggedIn: false, user: null, token: null };
    }
    return { isLoggedIn: true, user: JSON.parse(user), token };
}
// chaque îlot initialise SON store avec l'état préchargé
import { configureStore } from '@reduxjs/toolkit';
import { readAuthFromStorage } from '../auth/bootstrap';

const store = configureStore({
    reducer: { auth: authReducer, lecteur: lecteurReducer },
    preloadedState: {
        auth: readAuthFromStorage(), // hydraté avant le premier rendu : zéro flash « déconnecté »
    },
});

Le rôle de Redux est redéfini : il gère l'état interne à l'îlot pendant la vie de la page (filtres du lecteur, progression du quiz). L'état qui doit traverser les pages — la session — vit dans localStorage, et le store n'en est qu'un miroir local réhydraté au montage.

Ce que je retiens

  • Avec Turbo, chaque navigation recrée les îlots React et leurs stores : tout état Redux est éphémère par construction.
  • La question d'architecture n'est pas « comment faire survivre le store » mais « quel état doit réellement survivre » — et cet état-là vit dans localStorage, pas dans Redux.
  • preloadedState hydrate le store avant le premier rendu : pas de flash « déconnecté » ni de useEffect de rattrapage.
  • Les habitudes du monde SPA (store immortel, état global unique) ne se transposent pas telles quelles dans une architecture en îlots — chaque paradigme a ses invariants.