Il contesto

Dopo la migrazione di baccalaureat.sn verso Symfony + Turbo (raccontata in un articolo precedente), l'architettura è diventata: pagine Twig renderizzate lato server, e delle isole React per le zone interattive — il lettore PDF, il quiz, l'assistente IA. Ogni isola incorpora il proprio store Redux.

Il sintomo

L'utente effettua l'accesso sulla pagina iniziale: l'isola di autenticazione mostra correttamente il suo profilo. Naviga verso un'annata... e il lettore PDF lo tratta come visitatore anonimo: contenuti premium bloccati, preferiti assenti. Ritorno alla pagina iniziale: di nuovo connesso. Ogni pagina sembrava avere la propria opinione su chi fosse connesso.

La causa: uno store per ciclo di vita della pagina

La spiegazione sta in una frase che bisogna aver interiorizzato: uno store Redux vive nella memoria JavaScript della pagina, e Turbo sostituisce la pagina. Anche se Turbo evita il ricaricamento completo del browser, ogni navigazione sostituisce il <body> e rimonta le isole React della nuova pagina — che ricreano il loro store allo stato iniziale.

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

In una SPA classica, lo store viene creato una volta e sopravvive a tutte le «navigazioni» (che sono solo cambi di rotta lato client). Con Turbo, questo presupposto cade. Il mio codice veniva dal mondo SPA e presupponeva uno store immortale.

La falsa pista: persistere tutto lo store

Primo riflesso: redux-persist, per salvare e reidratare l'intero store a ogni pagina. Ho fatto marcia indietro rapidamente — persistere l'interezza dello stato (liste di prove, filtri, cache PDF) tra pagine che non hanno le stesse isole significa sincronizzare a forza mondi che non hanno nulla da dirsi, con i bug di reidratazione in più.

La soluzione: la fonte di verità fuori dallo store

La vera lezione di architettura: l'autenticazione non deve «vivere» in Redux quando le pagine sono effimere. Il JWT vive in localStorage — che, lui, sopravvive alle navigazioni — e ogni isola si idrata da questa fonte al montaggio:

// 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é »
    },
});

Il ruolo di Redux viene ridefinito: gestisce lo stato interno all'isola durante la vita della pagina (filtri del lettore, avanzamento del quiz). Lo stato che deve attraversare le pagine — la sessione — vive in localStorage, e lo store ne è solo uno specchio locale reidratato al montaggio.

Cosa mi porto dietro

  • Con Turbo, ogni navigazione ricrea le isole React e i loro store: ogni stato Redux è effimero per costruzione.
  • La questione di architettura non è «come far sopravvivere lo store» ma «quale stato deve realmente sopravvivere» — e quello stato vive in localStorage, non in Redux.
  • preloadedState idrata lo store prima del primo render: nessun flash «disconnesso» né useEffect di recupero.
  • Le abitudini del mondo SPA (store immortale, stato globale unico) non si trasferiscono tali e quali in un'architettura a isole — ogni paradigma ha i suoi invarianti.