CR React Paris 2025 : Goodbye, useState
React Paris s'est tenu les 20 et 21 mars 2025 à Paris. Nous vous proposons une série de compte-rendu sur les talks qui nous ont le plus marqués.
Après Goodbye useEffect sur pourquoi on n’aurait peut être pas besoin de useEffect, David Khourshid critique notre dépendance à useState pour la gestion d'état et nous présente des solutions alternatives plus efficaces. useState est en effet souvent sur-utilisé.
Le batching
Le premier problème du useState est le batching
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}}
>
Count: {count}
</button>
);
Quel est le décompte final si nous cliquons sur le bouton ? Si vous avez indiqué autre chose que 1, consultez la documentation React à ce sujet: queueing a Series of State Updates.
David insiste sur l'importance d’utiliser correctement les setters pour éviter des problèmes liés à l’identité des objets notamment avec les Map et les Set qui ne fonctionnent pas bien avec le setter. En effet, dans ce cas, on modifie directement l’objet existant (mutation) sans en créer un nouveau. Or, React déclenche un re-render uniquement si la nouvelle valeur du state est différente par référence de l’ancienne.
Il est également important de faire attention aux re-renders inutiles. On peut souvent remplacer un state par une ref.
Le règle est d'utiliser useState quand on a besoin de re-render un composant et de le garder aussi local que possible, et useRef pour du state interne qui ne devrait pas re-render.
Par exemple, pour les valeurs de formulaires, où pour du tracking analytics on utilise useRef:
const email = useRef('');
const password = useRef('');
const handleSubmit = (e) => {
e.preventDefault();
console.log(email.current, password.current);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email.current}
onChange={(e) => (email.current = e.target.value)}
/>
<input
type="password"
value={password.current}
onChange={(e) => (password.current = e.target.value)}
/>
<button type="submit">
Update
</button>
</form>
);
const clickedCount = useRef(0);
const handleClick = () => {
// On update la ref. Cela ne déclenche pas de nouveau rendu
clickedCount.current += 1;
analytics.trackEvent('button_clicked', { count: clickedCount });
}
return (
<Button
onClick={handleClick}
>
Click me!
</Button>
)
D'ailleurs, pour optimiser les envois aux analytics, jetez un coup d'œil à notre compte rendu de la Duck Conf sur des millisecondes contre des millions.
Comment peut-on supprimer certains usages de useState ?
Les paramètres d'url
On peut utiliser les paramètres d'url (search params) pour persister du state tel que des filtres, de la recherche, etc. Cela améliore également l'UX en permettant de partager l'url, de l'avoir dans l'historique et de conserver l'état entre deux reload. Pour cela, l'utilisation de nuqs est recommandée. Son créateur, François Best, en a aussi donné un Talk à Paris React: type Safe URL State Management in React with nuqs.
Loader de la donnée
On utilise souvent un useEffect dans lequel on fetch la donnée, combiné à un useState. Le problème est que cette implémentation est plutôt naïve et ne gère pas tout ce dont on a besoin. On se retrouve vite à ajouter un nouveau state pour le loading ou l'erreur. Même ainsi, ça ne gère pas tout. La solution: TanStack Query (aussi appelé React Query). L'article why React Query expose parfaitement les problèmes que j'indique ici, et comment React Query y apporte une solution.
Suspense
On peut également utiliser Suspense. Il permet d'afficher un fallback jusqu'à ce que ses enfants aient fini de se charger.
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
useTransition
Le hook useTransition permet de gérer pour nous les states d'une Promise.
function SubmitButton({ submitAction }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
submitAction();
});
}}
>
{!isPending ? "Submit" : "Submitting"}
</button>
);
}
Avec un useState, on aurait:
import { useState } from "react";
function SubmitButton({ submitAction }) {
const [isPending, setIsPending] = useState(false);
const handleClick = async () => {
setIsPending(true);
try {
await submitAction(); // si submitAction est async
} finally {
setIsPending(false);
}
};
return (
<button disabled={isPending} onClick={handleClick}>
{!isPending ? "Submit" : "Submitting"}
</button>
);
}
Les formulaires
Pour les formulaires, on se retrouve souvent à avoir beaucoup de state, en général un par propriété.
En React, nous avons la possibilité d’avoir des formulaires contrôlés (controlled) ou non (uncontrolled).
Un formulaire contrôlé va dupliquer les données du formulaire provenant du navigateur dans un state.
Une des raisons pour laquelle on aurait besoin d’un state est parce qu’on a besoin d’afficher cette valeur autre part que dans le formulaire lorsqu’on le remplit. On a cependant pas besoin de cette duplication dans la plupart des cas. C’est ici que le formulaire non contrôlé entre en jeu.
Un formulaire non contrôlé n’utilise pas de state et se repose uniquement sur le navigateur. FormData est une API standard du web gérée par le navigateur qui permet d’accéder à la donnée du formulaire directement.
On peut le coupler à une librairie de validation de données telle que zod.
C’est exactement cette méthode qui est utilisée par les frameworks React tel que Remix et nextjs.
// schema zod de validation de notre donnée
const UserSchema = z.object({
firstName: z.string(),
})
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.target);
const data = UserSchema.parse(Object.entries(formData));
}
return (
<form onSubmit={handleSubmit}>
{/* form content */}
</form>
)
Si on veut vraiment utiliser un formulaire contrôlé avec une logique complexe, il est fortement conseillé d’utiliser une librairie qui gère tout pour nous (validation, onBlur, etc).
Personnellement, j'utilise react-hook-form mais il existe aussi TanStack Form. Ces librairies s'intègrent parfaitement avec zod et nous permettent de gérer facilement la validation de la donnée.
Refactorer un nombre important de useState
Il arrive que l'on se retrouve avec un nombre important de useState. Par exemple pour un profil utilisateur, on en aurait un pour le prénom, un pour le nom, un pour l'âge, etc.
On peut les remplacer par un useState unique qui contient l'objet profile:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [age, setAge] = useState(0);
const [addresseStreet, setAddressStreet] = useState(0);
const [addresseCity, setAddressCity] = useState(0);
// devient
const [profile, setProfile] = useState({
personal: {
firstName: '',
lastName: '',
age: 0,
},
address: {
street: "",
city: "",
}
})
Pour mettre à jour:
const handleChange = (section, field, value) => {
setProfile(prevProfile => ({
...prevProfile,
[section]: {
...prevProfile[section],
[field]: value,
}
}))
}
Comme la donnée du state doit être immutable, on doit “spread” les objets, sous peine de se retrouver avec des bugs. C'est d'ailleurs une cause d'erreur fréquente en React. Mais avec cette technique, on peut souvent se retrouver avec beaucoup de spreads d'objets, rendant le code difficile à lire.
Immer
Pour simplifier la mise à jour d'objets complexes, on peut utiliser Immer et le hook useImmer qui vient remplacer le useState. La documentation React elle-même propose cette solution.
Immer simplifie l'utilisation des données, qui sont immutables et peuvent donc être modifiées directement sans spread d'objets. Le code est beaucoup plus lisible et les modifications plus faciles.
const [profile, setProfile] = useImmer({
personal: {
firstName: '',
lastName: '',
age: 0,
},
address: {
street: "",
city: "",
}
})
const handleFirstNameChange = (value) => setProfile(draft => {
draft.personal.firstName = value
})
Utiliser des étapes
Plutôt que d'avoir de nombreux useState avec des boolean pour décrire nos différentes étapes, on peut utiliser un seul useState contenant l'étape:
const [isStep1, setIsStep1] = useState(false)
const [isStep2, setIsStep2] = useState(false)
const [isStep3, setIsStep3] = useState(false)
const gotToStep3 = () => {
stateIsStep1(false);
stateIsStep2(false);
stateIsStep3(true);
}
// devient
const [step, setStep] = useState('STEP_1')
const gotToStep3 = () => {
stateStep('STEP_3');
}
Typescript discriminated union
type DataStatus = 'idle' | 'pending' | 'error' | 'success'
const [status, statStatus] = useState<DataStatus>('idle')
const [data, setData] = useState(null)
const [error, setError] = useState(null)
setStatus('pending')
setError(null)
try {
setStatus('success')
setData('result')
} catch (e) {
setStatus('error')
setError(e.message)
}
// devient
const DataState<T> = // discriminated union
| { status: 'idle' }
| { status: 'pending', }
| { status: 'error', error: string }
| { status: 'success', data: T }
const [state, setState] = useState<DataState<any>>({ status: 'idle' })
setState({ status: 'pending' })
try {
setState({ status: 'success', data: "result" })
} catch (e) {
setState({ status: 'error', error: e.message })
}
Ici, le type Typescript est beaucoup plus clair et garantit que les types sont bons en fonction du statut.
setState({ status: 'loading', data: "test" }) donnerait une erreur Typescript, nous assurant une bonne gestion de nos états.
Éliminer useState
Il arrive souvent qu'on calcule de la donnée dans un useEffect et qu'on le set dans un state. On ne devrait jamais sauvegarder le résultat d'un calcul dans un state. En effet le state est fait pour stocker la donnée source de vérité et non pas un dérivé.
- En stockant le résultat d’un calcul, on crée de la redondance ;
- Il y a un risque de désynchronisation de la donnée ;
La règle est la suivante: si on peut calculer une valeur à partir du state ou des props, on ne doit pas la mettre dans le state.
Une première solution est de tout simplement supprimer le useEffect et de recalculer directement notre donnée:
useEffect(() => {
setTotalItems(cart.reduce((sum, item) => sum + item.quantity, 0))
setPrice(cart.reduce((sum, item) => sum + item.price, 0))
}, [cart])
// devient
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0)
const price = cart.reduce((sum, item) => sum + item.price, 0)
Si le calcul est lourd, on peut utiliser useMemo :
const { totalItems, price } = useMemo(() => {
return {
totalItems: cart.reduce((sum, item) => sum + item.quantity, 0),
price: cart.reduce((sum, item) => sum + item.price, 0)
}
}, [cart])
useReducer
On peut utiliser useReducer quand la logique de l'UI est complexe et que les mises à jour du state sont interdépendantes. Celui-ci :
- Simplifie le code ;
- Peut être testé unitairement ;
- Apporte de l'observabilité.
Là où useState est direct - une valeur en entrée, une valeur en sortie - useReducer est indirect : on ne met pas à jour le state directement, on utilise des events pour indirectement le mettre à jour. Les events contiennent des informations que la mise à jour directe du state n'as pas :
- Causalité: qu'est-ce qui a causé le changement ;
- Contexte: les paramètres du changement ;
- Temporalité: quand le changement est arrivé ;
- Traçabilité: On peut loguer ou rejouer le changement.
"Le management de state direct est facile, le management de state indirecte est simple"
On peut également utiliser des actions de reduce pour limiter les mises à jour d'état.
useContext
Pour de la donnée globale et de la donnée simple, on peut utiliser useContext.
✅ Quand utiliser useContext :
- Partage de données globales simples : Idéal pour des données qui ne changent pas souvent ou qui ne déclenchent pas de nombreux re-renders. Exemples : thème (clair/sombre), langue, préférences utilisateur, utilisateur connecté (readonly), etc.
- Prop drilling à éviter : Lorsque l'on veut éviter de passer des props sur plusieurs niveaux de composants.
- Encapsuler un provider autour d’un composant : on peut créer des "scopes" locaux avec des contextes pour organiser ton code (par exemple, un FormProvider ou ModalProvider).
⚠️ useContext montre rapidement ses limites dès qu’on a :
- Des mises à jour fréquentes (ex: un compteur, des champs de formulaire en édition) → le re-render global de tous les composants consommateurs peut nuire aux performances.
- De la logique métier complexe ou plusieurs états dépendants entre eux → des librairies de store sont plus adaptées, où useReducer si la donnée reste locale.
- Des besoins de performance et de scalabilité.
- Besoin de code testable et isolé → difficile avec un contexte imbriqué ou partagé sans contrôle sur les updates.
Dans ces cas-là, il est recommandé d’utiliser une librairie de gestion d’état plus spécialisée.
Librairies de state management
Pour gérer de la logique dans un state global on peut utiliser différentes librairies. Voici quelques options populaires, avec leurs forces et faiblesses.
TanStack Query peut également être utilisé pour de la gestion de state globale car elle permet de gérer, cacher et synchroniser des données de n'importe quel Promise.
- ✅ Idéal pour les données serveur partagées globalement, cache intelligent, évite d’écrire du boilerplate Redux
- ❌ Pas adapté pour de la "client state" pure (UI state ou formulaire complexe), nécessite une API externe pour fonctionner Pour en savoir plus, cet article de la documentation de React Query: does React Query replace Redux, MobX or other global state managers?
Zustand est une librairie minimaliste de gestion de state basée sur des hooks, qui permet de créer des stores simples sans boilerplate.
- ✅ Très légère, intuitive et facile à intégrer
- ❌ Moins adaptée aux cas très complexes ou avec des effets secondaires nombreux
Redux est une solution plus ancienne et très connue, avec un store centralisé, des reducers et des actions.
- ✅ Prédictibilité, gros écosystème, bon pour les applications complexes
- ❌ Beaucoup de boilerplate, courbe d’apprentissage plus raide. À noter qu'avec redux-toolkit le boilerplate est grandement réduit
De nombreuses autres librairies existent, parmi lesquelles on peut citer Jotai, XState Store et Recoil.
Librairies de "local first"
Ces librairies adoptent une approche un peu différente des librairies classiques de gestion d'état : elles visent à combiner la rapidité et la réactivité du stockage local (comme un store local ou une base de données embarquée) avec la synchronisation automatique vers le cloud ou entre plusieurs clients.
ElectricSQL utilise CRDTs (Conflict-free Replicated Data Types) pour permettre la mise à jour du state en local, même hors-ligne, puis synchronise automatiquement les changements avec une base distante (PostgreSQL typiquement).
- ✅ Offline-first, synchro automatique, pas de conflit, très adapté au collaboratif.
- ❌ Plus complexe à mettre en place, nécessite une base backend compatible, ajoute de la couche infra.
Replicache est une librairie JavaScript conçue pour les apps web avec latence minimale et synchronisation optimiste. Les utilisateurs peuvent interagir instantanément, et les changements sont synchronisés en arrière-plan.
- ✅ Super fluide pour l'utilisateur, excellent pour les apps en temps réel ou collaboratives.
- ❌ Requiert un serveur proxy spécifique, pas trivial à intégrer dans toutes les stacks.
Une autre possibilité est d’utiliser le couple PouchDB + CouchDB.
Conclusion
David Khourshid a listé ici de nombreuses bonne pratiques qui matchent également celles que je mets en place sur mes projets en React:
- react-query
- react-hook-form couplé avec Zod.
- Jotai ou Remix en fonction de la complexité du projet
- useImmer pour les cas de useState où la donnée à éditée est dans un sous-objet / complexe.
- nuqs pour les paramètres d'url
J'utilise également un hook useLocalStorage qui fonctionne comme useState mais synchronise la donnée avec le local storage du navigateur.
Pour récapituler : quand-est qu'on utilise quoi ?
Problématique | Solution |
---|---|
“Je n’ai pas besoin de trigger un re-render” | useRef |
Données de filtres et de recherche | Dans l’url avec la librairie nuqs |
Charger de la donnée asynchrone, faire des appels API | TanStackQuery |
Calculer de la donnée depuis un state ou une prop | Calcul direct ou useMemo |
Gestion de state local simple | useState() / useLocalStorage() |
Gestion de state local complexe sans logique métier | useImmer() |
Changement complexe avec logique métier | useReducer() |
State global, partagé entre plusieurs composants avec des updates simples | useContext() |
State complexe, avec de la logique | Librairies de state management |
Gestion de formulaire simple | FormData et validation avec zod |
Gestion de formulaire complexe | react-hook-form ou TanStackForm avec zod |
Vous pouvez retrouver le replay de ce talk sur Youtube.