TL;DR
- Redux confondait deux choses très différentes : l'état serveur (cache d'API) et l'état local/UI. Les séparer est tout le secret.
- État serveur → React Query. État UI → Zustand. Redux disparaît domaine par domaine.
- Migration dual-store incrémentale : les trois stores coexistent, l'ancien et le nouveau tournent côte à côte en prod.
- On prouve le pattern sur le flux de login d'abord, borné, à forte valeur, puis on scale. Les features à risque en dernier.
- Résultat : zéro fenêtre de maintenance, refresh de token sans boucle infinie, invalidation de cache chirurgicale, et un mode offline-first.
Le point de départ : Redux qui fait tout, donc rien de bien
Cette app tournait sur du Redux classique : actions, reducers, thunks. Le problème n'était pas Redux en soi, c'est un outil solide. Le problème, c'est qu'on lui faisait porter deux responsabilités incompatibles dans le même store. D'un côté, le cache des données serveur : profil utilisateur, planning de cours, stats, progression vidéo. De l'autre, l'état purement local de l'interface : un onglet sélectionné, un flag « en ligne », un brouillon de formulaire.
Tout passait par le même tuyau, avec la même cérémonie : une action, un reducer, un thunk, un selector, un useSelector, des flags isLoading écrits à la main. Multiplié par chaque domaine métier, ça donnait une marée de boilerplate. Et surtout, des bugs de cache rance : des données serveur qui restaient affichées alors qu'elles avaient changé, parce que personne n'avait pensé à les invalider après telle mutation.
La vraie cause n'était pas « trop de Redux ». C'était de traiter une réponse d'API comme si c'était de l'état applicatif. Une donnée serveur n'est pas un état que tu possèdes, c'est un cache d'une vérité qui vit ailleurs.
L'insight qui débloque tout : deux états, pas un
Avant d'écrire une ligne de migration, il a fallu nommer le problème correctement. Il y a deux natures d'état dans une app, et les confondre est la racine de la plupart des galères de state management :
- L'état serveur, tout ce qui vient d'une API : il est asynchrone, partagé, il peut devenir périmé, il doit être rafraîchi, mis en cache, invalidé. Sa maison naturelle, c'est
React Query. - L'état local / UI, ce que tu possèdes vraiment côté client : préférences, sélections, flags d'interface. Synchrone, simple, à toi. Sa maison naturelle, c'est
Zustand.
C'est cette distinction qui fait « cliquer » React Query. Tant qu'on pense React Query comme « un Redux en moins verbeux », on passe à côté. C'est un cache serveur avec une politique de fraîcheur, pas un store applicatif. Une fois ce cadrage posé, la migration cesse d'être un remplacement ligne à ligne pour devenir un tri : chaque morceau de l'ancien store va d'un côté ou de l'autre.
La stratégie : dual-store, et surtout pas de big-bang
Réécrire en bloc une app à fort trafic, c'est signer pour une nuit de migration, un drapeau de feature géant, et une journée à recoller les morceaux quand un coin oublié casse. Non. La seule approche viable sur du vivant, c'est l'incrémental coexistant : le nouveau monde pousse à côté de l'ancien, et on déplace les domaines un par un, en production, sans que l'utilisateur voie quoi que ce soit.
- Phase 1, cohabitation. On monte React Query et Zustand à côté de Redux. Les trois stores vivent ensemble. Un domaine migré sort de Redux ; les autres y restent, intacts. Aucune date butoir, aucun gel de feature.
- Phase 2, prouver sur le login. On valide le pattern complet sur un flux borné et à forte valeur : l'authentification. S'il tient là, il tiendra partout.
- Phase 3, les lectures. On migre les flux de lecture derrière des garde-fous, et on centralise toute la surface d'API.
- Phase 4, le risque en dernier. Les features les plus sensibles (paiement, abonnements, données critiques) migrent à la toute fin, quand le pattern est rodé et la confiance acquise.
Phase 2 en détail : le login comme tête de pont
Le login est le candidat parfait pour un premier domaine : il est bien borné (un formulaire, un appel, un succès ou un échec), il est à forte valeur (tout le monde y passe), et il touche au cœur du nouveau découpage, il écrit de l'état serveur et de l'état utilisateur. Si on sait le faire proprement ici, on tient le modèle pour tout le reste.
Avant, le login était un thunk : dispatch(loginThunk()), avec des flags isLoading posés à la main dans le reducer, et une gestion d'erreur éparpillée. Après, c'est une simple mutation encapsulée dans un hook useLogin(). Tout devient déclaratif :
onSuccess→ on écrit l'utilisateur dans Zustand, puis on appellequeryClient.invalidateQueries()pour que les données dépendantes se rechargent fraîches.onError→ on délègue à unuseApiErrorManager()centralisé, qui mappe les codes d'erreur en messages utilisateur. Plus detry/catchcopié-collé partout.
// AVANT, thunk Redux + flags manuels
dispatch(loginThunk(credentials));
// reducer: state.isLoading = true / false posés à la main
// erreurs gérées au cas par cas dans chaque écran
// APRÈS, une mutation, tout est déclaratif
const useLogin = () =>
useMutation({
mutationFn: (creds) => api.login(creds),
onSuccess: (data) => {
useUserStore.getState().setUser(data.user); // état UI → Zustand
queryClient.invalidateQueries(); // cache serveur → refetch
},
onError: (err) => apiErrorManager.handle(err), // gestion centralisée
});
Phase 3 : migrer les lectures derrière des garde-fous
Une fois le login en place, l'utilisateur est dans Zustand et le queryClient est armé. On peut migrer les flux de lecture, planning, stats, progression. Mais attention au piège : si une query se déclenche avant que l'utilisateur soit connu, ou pour un compte inactif, on tire des requêtes inutiles, parfois des 401 en cascade.
La parade tient en une option de React Query : enabled. Chaque query reste en sommeil tant que ses préconditions ne sont pas réunies. C'est simple, mais c'est ce qui rend la coexistence sûre : pendant la transition, rien ne se déclenche prématurément.
// une query ne part que si l'utilisateur est connu ET actif
const usePlanning = () =>
useQuery({
queryKey: ['planning', user?.id],
queryFn: () => api.getPlanning(user.id),
enabled: !!user && isActiveUser, // garde-fou : pas de tir à blanc
});
Deuxième décision structurante : centraliser toute la surface d'API. Les ~54 hooks de query et de mutation vivent dans un seul fichier useQueries.ts. Un seul endroit pour savoir ce que l'app demande au serveur, comment les clés de cache sont nommées, et qui invalide quoi. Fini les appels d'API dispersés dans les écrans, c'est devenu la couche d'accès, point.
queryKey est ce qui fait ou défait l'invalidation. Les regrouper rend la cartographie « telle mutation invalide telles queries » lisible d'un coup d'œil, et c'est exactement ce dont on a besoin pour la partie suivante.Le morceau difficile n°1 : refresh de token sans boucle infinie
Le piège classique du refresh de token : un appel renvoie 401, on déclenche un refresh, le refresh lui-même peut échouer ou être lent, d'autres appels partent pendant ce temps, prennent eux aussi un 401, déclenchent chacun un refresh… et voilà une tempête de refresh ré-entrants qui se mordent la queue.
La solution s'articule autour d'un interceptor de réponse Axios et d'un verrou. L'interceptor attrape les 401, déclenche la mutation de refresh, met à jour l'utilisateur dans Zustand, puis rejoue la requête. Et surtout, un flag isLoadingRefresh sert de porte : tant qu'un refresh est en cours, les autres 401 attendent ce refresh-là au lieu d'en lancer un nouveau.
// interceptor de réponse, un seul refresh à la fois
api.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401 && !isLoadingRefresh) {
isLoadingRefresh = true; // GATE : on ferme la porte
try {
const fresh = await refreshMutation(); // un seul refresh
useUserStore.getState().setUser(fresh); // MAJ Zustand
return api(error.config); // on rejoue la requête
} finally {
isLoadingRefresh = false; // on rouvre la porte
}
}
return Promise.reject(error);
});
Le morceau difficile n°2 : invalider le cache chirurgicalement
Quand un utilisateur termine une vidéo, deux choses changent côté serveur : ses stats et son planning. La tentation paresseuse, c'est de tout réinvalider d'un coup. Mauvaise idée sur mobile : on rejoue des dizaines de requêtes inutiles, l'app rame, la batterie souffre. La bonne approche, c'est d'invalider uniquement les queries réellement affectées.
Mieux : l'invalidation est conditionnelle selon le type de rafraîchissement. Tout ne doit pas se recharger dans tous les cas. Et la mutation de progression elle-même est un create-or-update : première vidéo regardée → on crée la progression ; reprise d'une vidéo entamée → on met à jour l'existante. La même intention utilisateur, deux chemins serveur, gérés proprement par une seule mutation.
const useWatchVideo = () =>
useMutation({
mutationFn: (p) => api.upsertProgression(p), // create OR update
onSuccess: (_, vars) => {
// invalidation chirurgicale : SEULEMENT ce qui change
queryClient.invalidateQueries({ queryKey: ['userStats', user.id] });
queryClient.invalidateQueries({ queryKey: ['planning', user.id] });
// conditionnel : on ne réinvalide pas tout à chaque fois
if (vars.refreshType === 'full') {
queryClient.invalidateQueries({ queryKey: ['progression'] });
}
},
});
Le morceau difficile n°3 : tenir hors-ligne
Une app fitness, on l'utilise dans une salle au sous-sol, dans le métro, partout où le réseau est capricieux. L'offline-first n'est pas un bonus, c'est une exigence. Trois pièces s'emboîtent :
- Un listener NetInfo alimente un flag
isOnlinedans Zustand. C'est la source de vérité de l'app sur la connectivité. - Les queries ne se déclenchent pas automatiquement hors-ligne, on n'envoie pas de requêtes vouées à échouer, et on évite le scintillement d'erreurs.
- Le store Zustand est persisté (AsyncStorage) : l'utilisateur et l'état UI survivent à un redémarrage de l'app. Au retour du réseau, un refetch manuel resynchronise tout.
Avant / après, en une grille
Le bénéfice se résume bien dans le quotidien du code. Tout ce qui était cérémonie manuelle devient une primitive du framework :
REDUX (avant) → REACT QUERY (après)
─────────────────────────────────────────────────────────────
dispatch(fetchX()) → useX()
selector + memo à la main → automatique (cache + dédup)
invalidation manuelle du cache → queryClient.invalidateQueries()
isLoading posé dans le reducer → { data, isLoading, isFetching, error }
La dernière ligne est ma préférée : là où il fallait gérer un seul isLoading à la main (et souvent oublier le cas du re-fetch en arrière-plan), React Query offre gratuitement isLoading, isFetching et error distincts. La nuance « premier chargement » vs « rafraîchissement silencieux » devient triviale à afficher correctement.
Ce que je retiens
- Ne jamais big-bang une app vivante. Sur une app à fort trafic, la migration en bloc est un pari que tu n'as pas le droit de perdre. L'incrémental, lui, est réversible à chaque étape.
- La coexistence dual-store, c'est toute l'astuce. Faire tourner l'ancien et le nouveau côte à côte n'est pas une dette, c'est ce qui rend la migration sûre et sans downtime.
- Sépare l'état serveur de l'état UI. Cette seule distinction est ce qui fait « cliquer » React Query. Tant qu'on la rate, on réinvente Redux en moins verbeux.
- Choisis un flux borné et à forte valeur pour dé-risquer. Le login a servi de tête de pont : prouver le pattern une fois, là où ça compte, avant de le passer à l'échelle.
Ce genre de migration, partir de la bonne distinction, faire cohabiter l'ancien et le neuf, dé-risquer sur un flux borné, c'est exactement le travail d'architecture que j'aime : faire évoluer un système vivant sans jamais éteindre la lumière. La vraie question n'est jamais « est-ce que le nouveau code est plus beau ? » mais « comment on y arrive sans casser ce qui tourne déjà ? »
Un système legacy à faire évoluer sans tout casser ?
C'est le genre de mission où je suis le plus utile : faire migrer une architecture en production, sans downtime, sans big-bang, sans réveiller les utilisateurs. Migration de couche d'état, refonte d'API, dette technique sous tension, on en parle ?
Travailler avec moi →