
Je fetchais mes données "à la React."
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, []);
Ça marchait. Je livrais. Je passais à autre chose.
Puis j'ai commencé à remarquer des trucs. Les mêmes données fetchées deux fois. Des race conditions quand on navigue vite. Des composants qui clignotent. Des utilisateurs qui voient des données périmées après des mutations.
Je pensais que je faisais quelque chose de mal. En fait, je faisais exactement ce que les tutos m'avaient appris. Le problème, c'était le pattern lui-même.
Les problèmes que j'ai découverts (à mes dépens)
1. Pas de cache, jamais
Chaque fois qu'un composant se monte, il fetch. Tu navigues ailleurs ? Tu reviens ? Fetch encore. Mêmes données, même endpoint, même gaspillage.
// Page A fetch /api/users
// L'utilisateur va sur Page B
// L'utilisateur revient sur Page A
// Page A fetch /api/users ENCORE
Mes utilisateurs attendaient les mêmes données qu'ils avaient vues 2 secondes avant.
2. Les race conditions sont là, cachées
Ça a l'air correct :
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
Jusqu'à ce qu'un utilisateur clique rapidement entre les profils. La requête pour user 1 part. La requête pour user 2 part. User 2 répond en premier. User 1 répond en second. Maintenant tu affiches les données de user 1 sur le profil de user 2.
Le fix existe (AbortController), mais personne ne l'utilise :
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [userId]);
C'est beaucoup de boilerplate pour quelque chose qui devrait être automatique.
3. Le refetch est manuel
L'utilisateur ajoute une todo. Maintenant tu dois refetch la liste. Mais la liste est dans un autre composant. Maintenant tu liftes le state, tu passes des callbacks, ou tu sors le context.
// TodoList.tsx
useEffect(() => {
fetchTodos().then(setTodos);
}, []);
// AddTodo.tsx
async function handleAdd() {
await createTodo(text);
// Comment je dis à TodoList de refetch ?
// Une prop onSuccess ? Du Context ? Du state global ?
}
Chaque mutation devient un problème de coordination.
4. Des loading states partout
Trois appels useState par fetch. Chaque composant. À chaque fois.
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
J'avais des composants avec 9 appels useState juste pour 3 fetchs différents.
5. Pas de mises à jour en arrière-plan
Les données deviennent périmées. L'utilisateur laisse l'onglet ouvert 10 minutes, revient, voit des infos obsolètes. Avec useEffect, tu devrais configurer des listeners de visibilité, des intervals, tout manuellement.
Le moment où j'ai changé
Je construisais un dashboard. Plusieurs widgets, chacun fetchant ses propres données. Certains widgets partageaient le même endpoint. Certains devaient refetch après des mutations dans d'autres widgets.
Mon code était un bordel. Du state lifté partout. Des callbacks passés à travers 4 niveaux. Des bugs de race condition que je n'arrivais pas à reproduire de façon consistante.
Un collègue m'a dit : "Utilise juste React Query."
J'ai résisté. "J'ai pas besoin d'une autre librairie. useEffect marche très bien."
Il m'a montré ça :
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json())
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Pas de useState. Pas de useEffect. Pas d'AbortController.
Et ensuite :
function AddUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) => fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser)
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
return (
<button onClick={() => mutation.mutate({ name: 'John' })}>
Ajouter
</button>
);
}
Quand la mutation réussit, chaque composant qui utilise ['users'] refetch automatiquement. Pas de props. Pas de callbacks. Pas de context.
J'étais convaincu.
Ce que TanStack Query te donne gratuitement
Cache automatique
Même queryKey = même cache. Plusieurs composants peuvent utiliser useQuery(['users']) et une seule requête part.
// Composant A
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Composant B (rendu en même temps)
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Résultat : UN fetch, les deux composants reçoivent les données
Déduplication automatique
Requête en cours ? Un nouveau composant se monte avec la même query ? Il attend la requête existante au lieu d'en lancer une en double.
Stale-while-revalidate
Affiche les données en cache immédiatement, refetch en arrière-plan, met à jour quand les données fraîches arrivent. Les utilisateurs voient le contenu instantanément.
Refetch intelligent
Les données se refetchent quand :
- La fenêtre reprend le focus
- Le réseau se reconnecte
- Tu invalides la query
- Le stale time configurable expire
Tout automatique. Tout configurable.
DevTools
Vois chaque query, son statut, son cache, quand elle a été fetchée. Debug les problèmes de données en quelques secondes.
La comparaison de code
Avant (useEffect) :
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
Après (TanStack Query) :
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
Même fonctionnalité. Moitié moins de code. Plus le cache, la déduplication, et le refetch en arrière-plan.
Quand j'utilise encore useEffect
Je n'ai pas supprimé useEffect de mon vocabulaire. Il a encore sa place :
- Event listeners : resize de fenêtre, raccourcis clavier
- Subscriptions : connexions WebSocket, mises à jour temps réel
- Manipulation DOM : gestion du focus, position de scroll
- Timers : intervals, timeouts
- Librairies tierces : initialisation de charts, maps, etc.
La règle que je suis maintenant : si ça fetch des données depuis une API, c'est pas le job de useEffect.
Et les Server Components ?
Si tu utilises Next.js App Router ou n'importe quel framework avec RSC, tu as une autre option : fetch directement dans les server components.
// Ça tourne sur le serveur
async function UserList() {
const users = await fetch('/api/users').then(res => res.json());
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Pas de hooks. Pas de fetch côté client. Les données chargent avant même que le composant arrive dans le navigateur.
Pour l'interactivité côté client (mutations, mises à jour optimistes, données temps réel), TanStack Query brille toujours. Ils se complètent :
- Server Components pour les données initiales
- TanStack Query pour les mutations côté client et la gestion du cache
Et le hook use() de React 19 ?
React 19 a introduit use(), une nouvelle façon de lire des promises directement dans les composants :
function UserProfile({ userPromise }) {
const user = use(userPromise); // Suspend jusqu'à résolution
return <h1>{user.name}</h1>;
}
// Le parent passe la promise
function App() {
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
C'est plus propre que useEffect pour les cas simples. Mais ça ne remplace pas TanStack Query pour :
- Le cache entre composants
- Le refetch en arrière-plan
- La gestion des mutations
- L'invalidation des queries
Vois use() comme une brique de base. TanStack Query est la solution complète.
J'ai couvert use() et tous les autres hooks React 19 en détail dans la partie 5 de ma série sur les hooks React.
La leçon
useEffect n'est pas mauvais. Il n'est juste pas fait pour le data fetching.
J'ai passé des mois à combattre les symptômes : race conditions, données périmées, coordination des refetch, boilerplate. Le vrai problème, c'était d'utiliser un outil de synchronisation pour un problème de cache.
TanStack Query n'est pas magique. C'est juste le bon outil pour le job. Et une fois que j'ai accepté que "utilise juste useEffect" n'était pas la réponse, mon code est devenu plus simple, mes bugs moins nombreux, et mon app plus rapide pour les utilisateurs.
Parfois la méthode difficile est la seule façon d'apprendre pourquoi la méthode facile existe.
C'est la partie 1 de ma série "Ce que j'ai appris à mes dépens". Prochain article : les erreurs de performance que j'ai faites sur ma première vraie app.
Continuer vers la partie 2 : Les erreurs de performance que j'ai faites sur ma première vraie app →
Des questions ? Contacte-moi sur LinkedIn ou découvre plus sur mon blog.