Gestion d'état React simplifiée : séparez l'état serveur de l'état client, suivez quelques règles et repérez tôt les signes d'une complexité croissante.

L'état est toute donnée qui peut changer pendant l'exécution de votre app. Ça inclut ce que vous voyez (un modal ouvert), ce que vous éditez (un brouillon de formulaire) et les données que vous récupérez (une liste de projets). Le problème, c'est que tout ça s'appelle « état », alors qu'ils se comportent très différemment.
La plupart des apps qui partent en vrille suivent la même pente : trop de types d'état se mélangent au même endroit. Un composant finit par contenir des données serveur, des flags UI, des brouillons de formulaire et des valeurs dérivées, puis essaie de les synchroniser avec des effets. Rapidement, vous ne pouvez plus répondre à des questions simples comme « d'où vient cette valeur ? » ou « qu'est-ce qui la met à jour ?» sans fouiller dans plusieurs fichiers.
Les apps React générées y tombent plus vite parce qu'il est facile d'accepter la première version qui marche. Vous ajoutez un nouvel écran, copiez un pattern, corrigez un bug avec un autre useEffect, et voilà deux sources de vérité. Si le générateur ou l'équipe change de direction en cours de route (état local ici, store global là), la base de code accumule des patterns au lieu de s'appuyer sur un seul.
L'objectif est ennuyeux : moins de types d'état, moins d'endroits à inspecter. Quand il y a une maison évidente pour les données serveur et une maison évidente pour l'état purement UI, les bugs deviennent plus petits et les changements moins risqués.
« Rester ennuyeux » signifie respecter quelques règles :
Un exemple concret : si une liste d'utilisateurs vient du backend, traitez-la comme server state et récupérez-la là où elle est utilisée. Si selectedUserId existe uniquement pour piloter un panneau de détails, gardez-la comme état UI proche de ce panneau. Mélanger les deux, c'est là que la complexité commence.
La plupart des problèmes d'état React viennent d'un mélange : traiter les données serveur comme de l'état UI. Séparez-les tôt et la gestion d'état reste calme, même quand l'app grandit.
L'état serveur appartient au backend : users, commandes, tâches, permissions, prix, feature flags. Il peut changer sans que votre app ne fasse quoi que ce soit (un autre onglet le met à jour, un admin le modifie, un job l'actualise, les données expirent). Parce que c'est partagé et changeant, il faut du fetch, du cache, du refetch et de la gestion d'erreurs.
L'état client, c'est ce qui n'intéresse que votre UI maintenant : quel modal est ouvert, quel onglet est sélectionné, un toggle de filtre, l'ordre de tri, une sidebar repliée, un brouillon de recherche. Si vous fermez l'onglet, il est normal de le perdre.
Un test rapide : « Puis-je rafraîchir la page et reconstruire ceci depuis le serveur ? »
Il y a aussi l'état dérivé, qui évite de créer de l'état en plus. C'est une valeur que vous pouvez calculer à partir d'autres valeurs, donc vous ne la stockez pas. Listes filtrées, totaux, isFormValid et « afficher l'état vide » appartiennent généralement ici.
Exemple : vous récupérez une liste de projets (server state). Le filtre sélectionné et le flag du dialog « Nouveau projet » sont du client state. La liste visible après filtrage est de l'état dérivé. Si vous stockez la liste visible séparément, elle va se désynchroniser et vous passerez votre temps à chercher « pourquoi elle est obsolète ? ».
Cette séparation aide quand un outil comme Koder.ai génère des écrans rapidement : gardez les données backend dans une couche de fetch, gardez les choix UI près des composants et évitez de stocker des valeurs calculées.
L'état devient pénible quand une donnée a deux propriétaires. Le moyen le plus rapide de garder les choses simples est de décider qui possède quoi et de s'y tenir.
Exemple : vous récupérez une liste d'utilisateurs et affichez les détails lorsqu'un est sélectionné. Une erreur classique est de stocker l'objet utilisateur complet en state. Stockez selectedUserId à la place. Gardez la liste dans le cache serveur. La vue de détails cherche l'utilisateur par ID, donc les refetchs mettent à jour l'UI sans code de sync en plus.
Dans les apps React générées, il est aussi facile d'accepter un état « utile » généré qui duplique les données serveur. Quand vous voyez du code qui fait fetch -> setState -> edit -> refetch, marquez une pause. C'est souvent le signe que vous êtes en train de construire une seconde base de données dans le navigateur.
L'état serveur, c'est tout ce qui vit sur le backend : listes, pages de détail, résultats de recherche, permissions, compteurs. L'approche ennuyeuse consiste à choisir un seul outil et à s'y tenir. Pour beaucoup d'apps React, TanStack Query suffit.
Le but est simple : les composants demandent des données, affichent les états de chargement et d'erreur, et ne se soucient pas du nombre d'appels fetch sous le capot. Cela compte particulièrement dans les apps générées parce que les petites incohérences se multiplient vite quand on ajoute des écrans.
Traitez les query keys comme un système de nommage, pas comme un après-coup. Gardez-les cohérentes : keys stables en tableau, n'incluez que les inputs qui changent le résultat (filtres, page, tri), et préférez quelques formes prévisibles à beaucoup d'one-offs. Beaucoup d'équipes factorisent la construction des keys dans de petits helpers pour que chaque écran suive les mêmes règles.
Pour les écritures, utilisez des mutations avec un handling explicite du succès. Une mutation doit répondre à deux questions : qu'est-ce qui a changé, et que doit faire l'UI ensuite ?
Exemple : vous créez une nouvelle tâche. En cas de succès, soit vous invalidez la query de la liste des tâches (pour qu'elle se recharge), soit vous mettez à jour le cache de façon ciblée (ajouter la nouvelle tâche dans la liste en cache). Choisissez une approche par fonctionnalité et restez consistant.
Si vous êtes tenté d'ajouter des appels de refetch à plusieurs endroits « pour être sûr », choisissez plutôt un mouvement ennuyeux unique :
L'état client est ce que le navigateur possède : un flag d'ouverture de sidebar, une ligne sélectionnée, le texte d'un filtre, un brouillon avant sauvegarde. Gardez-le proche de son utilisation et il reste généralement gérable.
Commencez petit : useState dans le composant le plus proche. Quand vous générez des écrans (par exemple avec Koder.ai), la tentation est de pousser tout dans un store global « au cas où ». C'est comme ça qu'on finit avec un store que personne ne comprend.
Ne remontez l'état que lorsque vous savez nommer le problème de partage.
Exemple : un tableau avec un panneau de détails peut conserver selectedRowId dans le composant tableau. Si une toolbar ailleurs sur la page en a aussi besoin, remontez-le dans le composant page. Si une route distincte (comme l'édition en masse) en a besoin, là un petit store peut avoir du sens.
Si vous utilisez un store (Zustand ou similaire), gardez-le focalisé sur une seule mission. Stockez le « quoi » (IDs sélectionnés, filtres), pas les « résultats » (listes triées) que vous pouvez dériver.
Quand un store commence à grossir, demandez-vous : est-ce toujours une seule fonctionnalité ? Si la réponse honnête est « à peu près », scindez-le maintenant, avant que la prochaine feature ne le transforme en un nœud d'état que personne n'ose toucher.
Les bugs de formulaire viennent souvent du mélange de trois choses : ce que l'utilisateur tape, ce que le serveur a sauvegardé, et ce que l'UI affiche.
Pour une gestion d'état ennuyeuse, traitez le formulaire comme état client jusqu'à la soumission. Les données serveur représentent la dernière version sauvegardée. Le formulaire est un brouillon. N'éditez pas l'objet serveur en place. Copiez les valeurs dans l'état brouillon, laissez l'utilisateur les modifier, puis soumettez et refetch (ou mettez à jour le cache) en cas de succès.
Décidez tôt ce qui doit persister quand l'utilisateur navigue ailleurs. Ce choix évite beaucoup de bugs surprises. Par exemple, le mode inline edit et les dropdowns ouverts devraient en général se réinitialiser, tandis qu'un brouillon de long wizard ou un message non envoyé peut persister. Persistez au reload seulement quand les utilisateurs l'attendent clairement (comme un checkout).
Conservez les règles de validation en un seul endroit. Si vous dispersiez les règles entre inputs, handlers de submit et helpers, vous finirez avec des erreurs incohérentes. Préférez un schéma unique (ou une fonction validate()), et laissez l'UI décider quand afficher les erreurs (on change, on blur ou on submit).
Exemple : vous générez un écran Edit Profile dans Koder.ai. Chargez le profil sauvegardé comme server state. Créez un état brouillon pour les champs du formulaire. Affichez « modifications non sauvegardées » en comparant brouillon vs sauvegardé. Si l'utilisateur annule, abandonnez le brouillon et montrez la version serveur. S'il sauvegarde, soumettez le brouillon, puis remplacez la version sauvegardée par la réponse serveur.
À mesure qu'une app générée grandit, il est courant de retrouver les mêmes données à trois endroits : état du composant, store global et cache. La solution n'est généralement pas une nouvelle librairie. C'est choisir une maison pour chaque morceau d'état.
Un flux de nettoyage qui marche dans la plupart des apps :
filteredUsers si vous pouvez le calculer depuis users + filter. Préférez selectedUserId à un objet selectedUser dupliqué.Exemple : une app CRUD générée par Koder.ai commence souvent par un useEffect de fetch plus une copie de la liste dans un store global. Après centralisation du server state, la liste vient d'une seule query, et « rafraîchir » devient une invalidation plutôt que de la synchro manuelle.
Pour le nommage, restez cohérent et ennuyeux :
users.list, users.detail(id)ui.isCreateModalOpen, filters.userSearchopenCreateModal(), setUserSearch(value)users.create, users.update, users.deleteL'objectif est une source de vérité par chose, avec des frontières claires entre état serveur et état client.
Les problèmes d'état commencent petit, puis un jour vous changez un champ et trois parties de l'UI ne sont pas d'accord sur la « vraie » valeur.
Le signe le plus clair est la duplication des données : le même user ou panier vit dans un composant, un store global et une cache de requête. Chaque copie se met à jour à un moment différent, et vous ajoutez du code juste pour les garder égales.
Un autre signe est le code de sync : des effets qui poussent l'état d'un côté à l'autre. Des patterns comme « quand la query change, mettez à jour le store » et « quand le store change, refetch » peuvent marcher jusqu'à ce qu'un cas limite produise des valeurs obsolètes ou des boucles.
Quelques drapeaux rouges rapides :
needsRefresh, didInit, isSaving qui ne disparaissent jamais.Exemple : vous générez un tableau de bord dans Koder.ai et ajoutez un modal Edit Profile. Si les données du profil sont stockées dans une query cache, copiées dans un store global et dupliquées dans un état local de formulaire, vous avez maintenant trois sources de vérité. La première fois que vous ajoutez du refetch en arrière-plan ou des updates optimistes, les incohérences apparaissent.
Quand vous voyez ces signes, le mouvement ennuyeux est de choisir un propriétaire unique pour chaque donnée et de supprimer les miroirs.
Stocker des choses « au cas où » est une des façons les plus rapides de rendre l'état pénible, surtout dans les apps générées.
Copier les réponses API dans un store global est un piège fréquent. Si les données viennent du serveur (listes, détails, profil utilisateur), ne les copiez pas par défaut dans un store client. Choisissez une maison pour les données serveur (habituellement le cache de requêtes). Utilisez le store client pour les valeurs UI que le serveur ignore.
Stocker des valeurs dérivées est un autre piège. Comptes, listes filtrées, totaux, canSubmit et isEmpty doivent généralement être calculés. Si les performances deviennent un vrai problème, mémoïsez, mais ne commencez pas par stocker le résultat.
Un méga-store unique pour tout (auth, modals, toasts, filtres, brouillons, flags d'onboarding) devient une poubelle. Séparez par frontières de fonctionnalité. Si l'état est utilisé par un seul écran, gardez-le local.
Context est idéal pour des valeurs stables (theme, current user id, locale). Pour des valeurs qui changent vite, il peut provoquer de larges rerenders. Utilisez Context pour le wiring, et l'état composant (ou un petit store) pour les valeurs UI à changements fréquents.
Enfin, évitez les noms incohérents. Des keys de query et des champs de store presque identiques créent des duplications subtiles. Choisissez une norme simple et respectez-la.
Quand vous sentez l'envie d'ajouter « juste une variable d'état de plus », faites un contrôle rapide de propriété.
D'abord, pouvez-vous pointer vers un endroit unique où le fetch et le cache serveur se passent (un outil de query, un ensemble de keys) ? Si les mêmes données sont fetchées dans plusieurs composants et aussi copiées dans un store, vous payez déjà des intérêts.
Deuxièmement, cette valeur est-elle nécessaire seulement dans un écran (comme « le panneau de filtre ouvert ») ? Si oui, elle ne devrait pas être globale.
Troisièmement, pouvez-vous stocker un ID au lieu de dupliquer un objet ? Stockez selectedUserId et lisez l'utilisateur depuis votre cache ou liste.
Quatrièmement, est-ce dérivé ? Si vous pouvez le calculer à partir de l'état existant, ne le stockez pas.
Enfin, faites un test trace d'une minute. Si un coéquipier ne peut pas répondre en moins d'une minute à « d'où vient cette valeur ? » (prop, état local, cache serveur, URL, store), corrigez la propriété avant d'ajouter plus d'état.
Imaginez une app d'admin générée (par exemple via un prompt dans Koder.ai) avec trois écrans : liste clients, page détail client, et formulaire d'édition.
L'état reste calme quand chaque donnée a une maison évidente :
La liste et les pages de détail lisent l'état serveur depuis un cache de requêtes. Quand vous sauvegardez, vous n'enregistrez pas les clients à nouveau dans un store global. Vous envoyez la mutation, puis laissez le cache se rafraîchir ou mettez à jour le cache.
Pour l'écran d'édition, gardez le brouillon local. Initialisez-le depuis le client récupéré, mais traitez-le séparément dès que l'utilisateur commence à taper. Ainsi, la vue détail peut se rafraîchir sans écraser des modifications en cours.
L'UI optimiste est souvent l'endroit où les équipes dupliquent tout. Vous n'en avez généralement pas besoin.
Quand l'utilisateur clique sur Sauvegarder, mettez à jour uniquement l'enregistrement client en cache et l'élément correspondant dans la liste, puis revenez en arrière si la requête échoue. Gardez le brouillon dans le formulaire jusqu'à la réussite. S'il y a une erreur, affichez-la et conservez le brouillon pour que l'utilisateur puisse réessayer.
Supposons que vous ajoutez l'édition en masse et qu'elle a aussi besoin des lignes sélectionnées. Avant de créer un nouveau store, demandez-vous : cet état doit-il survivre à la navigation et au reload ?
Les écrans générés peuvent se multiplier vite, et c'est génial jusqu'à ce que chaque nouvel écran apporte ses propres décisions d'état.
Rédigez une courte note d'équipe dans le repo : ce qui compte comme server state, ce qui compte comme client state, et quel outil possède chaque chose. Gardez-la assez courte pour que les gens la suivent réellement.
Ajoutez une petite habitude en PR : étiquetez chaque nouvel état comme server ou client. Si c'est du server, demandez « où ça se charge, comment c'est mis en cache, et qu'est-ce qui l'invalide ? » Si c'est du client, demandez « qui le possède, et quand est-ce qu'il se réinitialise ? »
Si vous utilisez Koder.ai (koder.ai), Planning Mode peut vous aider à vous mettre d'accord sur les frontières d'état avant de générer de nouveaux écrans. Un snapshot et un rollback vous donnent un moyen sûr d'expérimenter quand un changement d'état tourne mal.
Choisissez une fonctionnalité (comme edit profile), appliquez les règles de bout en bout, et laissez cet exemple être celui que tout le monde copie.
Commencez par étiqueter chaque morceau d'état comme server, client (UI) ou dérivé.
isValid).Une fois étiquetés, assurez-vous que chaque item a un seul propriétaire évident (cache de requêtes, état local du composant, URL, ou un petit store).
Utilisez ce test rapide : « Puis-je rafraîchir la page et reconstruire ceci depuis le serveur ? »
Exemple : une liste de projets est du server state ; l'ID de la ligne sélectionnée est du client state.
Parce que cela crée deux sources de vérité.
Si vous récupérez users puis les copiez dans useState ou un store global, vous devez alors les maintenir synchronisés pendant :
Règle par défaut : et ne créez d'état local que pour les préoccupations UI ou les brouillons.
Stockez les valeurs dérivées seulement lorsque vous ne pouvez vraiment pas les calculer de manière peu coûteuse.
Généralement, calculez-les à partir des entrées existantes :
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSavingSi les performances deviennent un vrai problème (mesuré), préférez ou de meilleures structures de données avant d'introduire un état stocké qui peut devenir obsolète.
Par défaut : utilisez un outil d'état serveur (souvent TanStack Query) afin que les composants puissent simplement « demander des données » et gérer les états de chargement/erreur.
Principes pratiques :
Gardez-le local jusqu'à ce que vous puissiez nommer un vrai besoin de partage.
Règle de promotion :
Ainsi, votre store global n'a pas vocation à devenir une décharge pour des flags UI aléatoires.
Stockez des IDs et de petits flags, pas des objets serveur complets.
Exemple :
selectedUserIdselectedUser (objet copié)Ensuite, affichez les détails en recherchant l'utilisateur dans la liste ou le cache. Les refetchs en arrière-plan et les mises à jour se passent correctement sans code de synchronisation supplémentaire.
Considérez le formulaire comme un brouillon (client state) jusqu'à ce que vous soumettiez.
Patron pratique :
Signes d'alerte courants :
needsRefresh, didInit, isSaving).Les écrans générés peuvent rapidement diverger. Une garde-simple :
Si vous utilisez Koder.ai, utilisez Planning Mode pour décider des frontières avant de générer de nouveaux écrans, et appuyez-vous sur les snapshots/rollback pour revenir en arrière si un changement d'état tourne mal.
useMemoÉvitez de parsemer des appels refetch() partout « pour être sûr ».
Cela évite d'éditer l'objet serveur « en place » et de lutter contre les refetchs.
La solution n'est généralement pas une nouvelle librairie : supprimez les miroirs et choisissez un unique propriétaire par valeur.