Architecture frontaleJune 13, 2026·11 min de lecture·Miracle KaluMiracle Kalu

Arrêtez de récupérer des données dans useEffect : un guide pour ingénieurs senior sur les Server Components

A clean diagram showing data flowing from server to browser in a modern React application.

L'habitude de récupérer des données dans useEffect est difficile à perdre

Quand les React Hooks sont arrivés, useEffect est devenu l'endroit par défaut pour récupérer des données. Les tutoriels l'enseignaient. Stack Overflow le récompensait. Chaque développeur junior a appris à mettre fetch dans un useEffect avec un tableau de dépendances vide et un état de chargement. Ça marchait. C'était aussi le début d'une longue liste de problèmes que nous sommes encore en train de nettoyer.

J'ai passé la dernière année à migrer une application Next.js du Pages Router vers l'App Router. Le plus grand changement n'était pas la structure des fichiers ou les nouvelles conventions de routage. C'était d'apprendre à cesser de chercher useEffect à chaque fois que j'avais besoin de données. Les Server Components changent la règle par défaut. Ils ne rendent pas la récupération côté client obsolète, mais ils en font l'exception plutôt que la norme.

Cet article traite de quand récupérer des données côté serveur, quand vous avez encore besoin du client, et comment éviter le pire des deux mondes.

Ce qui ne va pas avec useEffect pour la récupération de données

Récupérer des données dans useEffect semble simple jusqu'à ce qu'on essaie de le rendre robuste. Vous avez besoin d'un état de chargement, d'un état d'erreur, d'une logique d'annulation, d'une logique de nouvelle tentative, et d'un moyen d'empêcher le cache de devenir obsolète. Ensuite, vous multipliez cela par chaque composant qui a besoin de données. Très vite, votre application devient une collection de petits gestionnaires de données qui se comportent tous légèrement différemment.

Les problèmes les plus graves sont architecturaux. La récupération côté client envoie du JavaScript au navigateur, s'exécute après le paint initial, bloque l'interactivité pendant que la requête réseau se résout, et crée des cascades quand les composants parents doivent récupérer des données avant que les enfants ne puissent commencer. Elle couple aussi la source de données à l'arbre de composants, ce qui rend les tests et la réutilisation plus difficiles.

Il existe des cas légitimes de récupération côté client. Les interactions utilisateur, les mises à jour en temps réel, et tout ce qui dépend d'APIs disponibles uniquement dans le navigateur appartiennent au client. Mais la récupération de données par défaut pour une page ne devrait presque jamais commencer dans useEffect.

Les Server Components changent la règle par défaut

Dans l'App Router de Next.js, les composants sont des Server Components par défaut. Ils s'exécutent sur le serveur, peuvent être async, et peuvent récupérer des données directement au moment du rendu. Le résultat est envoyé au navigateur sous forme de HTML, ce qui signifie que l'utilisateur voit du contenu sans attendre que JavaScript démarre et fasse un autre aller-retour.

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

interface PostPageProps {
  params: Promise<{ slug: string }>;
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
      <PostBody body={post.body} />
    </article>
  );
}

Ce composant fait quelque chose qui semble étrange si vous venez du Pages Router. Il attend des données pendant le rendu. Il n'y a pas de useState, pas de useEffect, et pas de spinner de chargement rendu par le composant lui-même. L'état de chargement est géré par loading.tsx dans le même segment, et les erreurs par error.tsx. Le composant sait seulement comment transformer des données en markup.

Le changement de modèle mental est le suivant : le composant n'est pas une petite application qui gère son propre cycle de vie. C'est une fonction qui transforme une requête en HTML. La récupération de données fait partie de cette transformation, pas un effet secondaire du montage.

La couche de cache est le vrai gain

La récupération côté serveur dans Next.js ne concerne pas seulement l'endroit où le code s'exécute. Il s'agit aussi des sémantiques de cache qui l'accompagnent. L'API fetch dans les Server Components est étendue avec une option cache qui permet de décider si une requête doit être mise en cache, combien de temps elle reste valide, et quand elle doit être revalidée.

// Mis en cache pour la durée de la requête par défaut
const posts = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 },
}).then((res) => res.json());

L'option next.revalidate indique à Next.js de mettre en cache la réponse au niveau du Data Cache pendant une heure. Ce n'est pas la même chose que le cache du navigateur. C'est un cache côté serveur partagé entre les requêtes, ce qui signifie que l'API amont n'est appelée qu'une fois par heure et par URL unique, quel que soit le nombre d'utilisateurs qui visitent la page.

Il existe trois caches à comprendre :

  • Data Cache : met en cache les réponses fetch entre les requêtes et les déploiements.
  • Router Cache : met en cache les segments de route préfetchés dans le navigateur pour une navigation rapide.
  • Full Route Cache : met en cache le HTML rendu des routes statiques au moment du build ou après revalidation.

Quand ces couches travaillent ensemble, vous obtenez des pages rapides lors de la première visite et presque instantanées lors des navigations ultérieures. Quand elles se battent entre elles, vous obtenez un comportement déroutant où les mises à jour n'apparaissent pas ou où des données obsolètes persistent plus longtemps que prévu.

Où Suspense et le streaming entrent en jeu

Toutes les récupérations de données ne peuvent pas être attendues avant le rendu de la page. Certaines données sont lentes, secondaires, ou dépendent d'une interaction utilisateur. C'est là que les limites Suspense entrent en jeu. Vous enveloppez la partie asynchrone de l'arbre dans une limite Suspense et laissez Next.js streamer le reste de la page vers le navigateur pendant que les données lentes se résolvent.

import { Suspense } from "react";
import { PostContent } from "./PostContent";
import { RelatedArticles } from "./RelatedArticles";
import { RelatedSkeleton } from "./RelatedSkeleton";

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;

  return (
    <article>
      <PostContent slug={slug} />
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedArticles slug={slug} />
      </Suspense>
    </article>
  );
}

Le détail clé est que RelatedArticles est aussi un Server Component. Il peut être async et récupérer ses propres données. La limite Suspense permet un rendu progressif sans déplacer la récupération vers le client. L'utilisateur voit le contenu principal immédiatement et les articles connexes quand ils sont prêts.

Ce modèle est beaucoup plus difficile à reproprement reproduire avec useEffect. Il faudrait coordonner les états de chargement entre plusieurs composants, gérer des placeholders, et espérer que le navigateur ne peigne pas une séquence maladroite de spinners.

Quand la récupération côté client a encore du sens

Les Server Components ne sont pas une religion. Il y a des endroits où le client est le bon endroit pour récupérer des données.

  • Interactions utilisateur qui modifient les données : recherche en temps réel, filtres, pagination, tri. Ceux-ci appartiennent aux Client Components avec une bibliothèque comme TanStack Query ou SWR.
  • Données en temps réel : tableaux de bord en direct, notifications, chat. Le serveur ne peut pas pousser de mises à jour vers un rendu statique.
  • APIs disponibles uniquement dans le navigateur : géolocalisation, presse-papiers, stockage local, Bluetooth. Si les données dépendent du navigateur, récupérez-les côté client.
  • Données personnalisées après hydration : préférences utilisateur, articles récemment consultés, assignations de tests A/B.

La règle de base est simple. Si les données sont nécessaires pour rendre la page initiale, récupérez-les côté serveur. Si les données sont produites par une interaction ou n'existent que dans le navigateur, récupérez-les côté client.

Pagination avec paramètres de requête

L'une des premières questions des équipes lors de la migration vers les Server Components est de savoir comment gérer la pagination. Dans le Pages Router, la pagination vivait généralement dans useState ou useRouter. Dans l'App Router, l'URL est la source de vérité, et les Server Components reçoivent searchParams en tant que prop.

// app/blog/page.tsx
interface BlogPageProps {
  searchParams: Promise<{ page?: string; limit?: string }>;
}

export default async function BlogPage({ searchParams }: BlogPageProps) {
  const { page = "1", limit = "10" } = await searchParams;
  const pageNum = Math.max(1, parseInt(page, 10) || 1);
  const limitNum = Math.min(50, Math.max(1, parseInt(limit, 10) || 10));

  const { posts, total } = await getPosts({ page: pageNum, limit: limitNum });
  const totalPages = Math.ceil(total / limitNum);

  return (
    <main>
      <PostGrid posts={posts} />
      <PaginationControls
        page={pageNum}
        totalPages={totalPages}
        limit={limitNum}
      />
    </main>
  );
}

Le composant PaginationControls est un Client Component car il gère les clics et met à jour l'URL. Il ne récupère pas de données. Il construit seulement des liens.

"use client";

import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link";

interface PaginationControlsProps {
  page: number;
  totalPages: number;
  limit: number;
}

export function PaginationControls({
  page,
  totalPages,
  limit,
}: PaginationControlsProps) {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const buildHref = (nextPage: number) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set("page", String(nextPage));
    params.set("limit", String(limit));
    return `${pathname}?${params.toString()}`;
  };

  return (
    <nav aria-label="Pagination">
      <Link
        href={buildHref(page - 1)}
        aria-disabled={page <= 1}
        className={page <= 1 ? "disabled" : ""}
      >
        Précédent
      </Link>
      <span>
        Page {page} sur {totalPages}
      </span>
      <Link
        href={buildHref(page + 1)}
        aria-disabled={page >= totalPages}
        className={page >= totalPages ? "disabled" : ""}
      >
        Suivant
      </Link>
    </nav>
  );
}

Ce modèle garde la récupération de données côté serveur tout en laissant le navigateur gérer la navigation. L'URL est partageable, actualisable et respecte l'historique. Comme la récupération a lieu côté serveur, les données de page mises en cache bénéficient des mêmes sémantiques de cache fetch que toute autre requête de Server Component.

La partie la plus délicate consiste à s'assurer que la clé de cache reflète les paramètres de requête. Next.js inclut automatiquement searchParams dans la clé de cache pour les segments de route. Mais si vous enveloppez votre récupération de données dans unstable_cache ou une fonction de cache personnalisée, vous devez inclure les paramètres dans la clé vous-même.

import { unstable_cache } from "next/cache";

const getPostsCached = unstable_cache(
  async (page: number, limit: number) => getPosts({ page, limit }),
  ["posts-list"],
  { revalidate: 60, tags: ["posts"] },
);

Notez que unstable_cache utilise les arguments de la fonction pour construire la clé, donc page et limit font déjà partie de l'identité du cache. Si vous construisez votre propre cache, assurez-vous de faire de même. Oublier d'inclure les paramètres de pagination dans la clé de cache est le moyen le plus rapide d'afficher la page un sur chaque page.

Gestion des mutations et revalidation

La récupération côté serveur n'est que la moitié de l'histoire. Vous devez aussi mettre à jour des données. Dans l'App Router, les mutations sont gérées par les Server Actions. Une Server Action est une fonction asynchrone qui s'exécute sur le serveur et peut être appelée depuis un Client Component. Après la fin de l'action, vous pouvez revalider le cache pour que le prochain rendu voie des données fraîches.

"use server";

import { revalidatePath } from "next/cache";

export async function publishComment(formData: FormData) {
  const rawFormData = {
    postId: formData.get("postId") as string,
    body: formData.get("body") as string,
  };

  await db.insert("comments", rawFormData);

  revalidatePath(`/blog/${rawFormData.postId}`);
}
// CommentForm.tsx
"use client";

import { publishComment } from "./actions";

export function CommentForm({ postId }: { postId: string }) {
  return (
    <form action={publishComment}>
      <input type="hidden" name="postId" value={postId} />
      <textarea name="body" required />
      <button type="submit">Publier le commentaire</button>
    </form>
  );
}

Ce modèle garde la logique de mutation côté serveur tout en gardant l'UI interactive. La invalidation du cache est explicite. Vous décidez quels chemins deviennent obsolètes et doivent être re-rendus. C'est une fonctionnalité, pas un bug. L'invalidation implicite est la source de données fantômes que personne ne peut expliquer.

La stratégie de migration qui fonctionne vraiment

Passer de useEffect aux Server Components n'est pas une réécriture complète. Nous avons migré page par page, en commençant par les routes les plus statiques et les plus fréquentées. Le modèle était toujours le même.

  1. Identifier une page qui récupère des données dans useEffect.
  2. Déplacer la récupération dans un async Server Component.
  3. Ajouter ou mettre à jour loading.tsx et error.tsx pour ce segment.
  4. Déplacer l'interactivité côté client dans de plus petits Client Components avec "use client".
  5. Ajouter des limites Suspense autour des données lentes ou non critiques.
  6. Ajuster la mise en cache avec revalidate, cache: "no-store" ou le rendu dynamique selon les besoins.

La partie la plus difficile était de désapprendre le réflexe de gérer un état. Les développeurs avaient tendance à utiliser useState par habitude, même quand il n'y avait pas d'état à gérer. La revue de code est devenue le lieu où nous demandions : "Ces données ont-elles besoin du navigateur ?" Si la réponse était non, elles partaient sur le serveur.

Pièges courants

Le premier piège consiste à traiter les Server Components comme un remplacement des routes API. Ce ne sont pas des routes API. Les Server Components ne rendent qu'une fois par requête et ne doivent pas exécuter d'effets de bord comme l'envoi d'e-mails ou l'écriture de journaux d'une manière dépendante de l'ordre de rendu. Pour les actions et les effets de bord, utilisez les Server Actions ou les routes API.

Le deuxième piège est la sur-mise en cache. Le comportement de mise en cache par défaut est agressif, ce qui est excellent pour les performances et dangereux pour les données qui changent fréquemment. Nous avons appris à être explicites sur chaque fetch. Si une requête ne doit pas être mise en cache, dites-le.

const data = await fetch("https://api.example.com/stats", {
  cache: "no-store",
});

Le troisième piège est de déplacer trop de choses côté client. Chaque fois que nous ajoutions "use client" à un composant principalement présentationnel, nous perdions les avantages du rendu serveur pour ce sous-arbre. Nous gardons maintenant les Client Components aussi petits et feuillus que possible.

Ce que nous avons mesuré

Après avoir migré nos pages les plus fréquentées, nous avons observé trois améliorations. Le Time to First Byte a diminué car le serveur pouvait récupérer des données en parallèle du rendu au lieu d'attendre que le navigateur hydrate d'abord. Le Largest Contentful Paint s'est amélioré car le contenu arrivait sous forme de HTML, et non sous forme de payload JavaScript qui devait encore s'exécuter. Et la taille du bundle JavaScript a diminué car le code de récupération de données n'était plus envoyé au client.

L'amélioration qualitative était tout aussi importante. Les pages sont devenues plus faciles à tester car les dépendances de données étaient des arguments de fonction explicites au lieu d'être cachées dans des chaînes de hooks. Les composants sont devenus plus faciles à réutiliser car ils n'avaient plus besoin de savoir d'où venaient leurs données. Et le débogage est devenu plus simple car il y avait moins de conditions de course et de bugs d'annulation à poursuivre.

Takeaways

  • useEffect est un gestionnaire d'effets de bord, pas une couche de récupération de données. Le traiter comme tel crée des problèmes de chargement, d'erreur, de cache et d'annulation partout.
  • Récupérez les données côté serveur par défaut, surtout pour le rendu initial de page. Les Server Components peuvent être async et exploiter les couches de cache de Next.js.
  • Utilisez loading.tsx, error.tsx et les limites Suspense pour un rendu progressif. Ne construisez pas d'états de chargement dans chaque composant.
  • Réservez la récupération côté client aux interactions, aux mises à jour en temps réel et aux données disponibles uniquement dans le navigateur. Gardez les Client Components petits et feuillus.
  • Utilisez les Server Actions pour les mutations et invalidez les caches explicitement. L'invalidation implicite est la source de données fantômes.
  • La migration est incrémentale. Commencez par les pages statiques et fortement fréquentées, puis progressez.
  • Soyez explicite sur la mise en cache. Les paramètres par défaut agressifs sont géniaux jusqu'à ce qu'ils cachent des données obsolètes.

Partager :

XLinkedIn
Miracle Kalu

Écrit par

Miracle Kalu

Senior Full Stack Engineer

Vous avez aimé cet article ?

Je suis disponible pour des rôles d'ingénierie senior et du conseil technique. Parlons-en.

Prendre contact →

Publié le 13 juin 2026 · 11 min de lecture