Accueil / Articles / Traçer correctement la frontière « use client » dans l’App Router de Next.js

Traçer correctement la frontière « use client » dans l’App Router de Next.js

Découvrez ce que la directive ‘use client’ indique réellement, pourquoi l’étiquetage des conteneurs alourdit les bundles, et comment les composants de base ainsi que les emplacements enfants maintiennent le travail du serveur sur celui-ci.

1190 mots

Ouvrez de nombreux projets basés sur App Router et vous trouverez 'use client' en haut de presque chaque composant. Cette habitude se développe rapidement : vous ajoutez un onClick ou un useState pour un menu déroulant, la compilation émet des erreurs, mais la directive fait disparaître ces problèmes. En répétant cet exercice une dizaine de fois, l’ensemble du code finit par être inclus dans le bundle du navigateur, ce qui crée une application single-page avec des formalités supplémentaires. Ce guide explique ce que fait réellement cette directive et comment l’utiliser afin que le traitement serveur reste sur le serveur et que seuls les éléments véritablement interactifs fassent charger du JavaScript.

Ce que la directive marque réellement

Le nom suggère « exécuter ceci uniquement dans le navigateur », mais ce n’est pas son sens. 'use client' définit une frontière de module : le fichier où s’arrête le graphe des modules serveur et où commence le bundle client. Les composants clients sont tout de même pré-renderis en HTML sur le serveur ; ils sont ensuite hydratés dans le navigateur.

[ Server Component Tree ] (Executes solely on the server; zero KB client JS)
         │
         ├── Server Component A (Fetches directly from DB)
         │
         ▼
── [ 'use client' Boundary ] ──
         │
         ├── Client Component B (Pre-rendered to HTML on server, hydrated on client)
         │       │
         │       └── Regular Component C (Now forced into the client bundle!)

La conséquence qui complique les choses est transitive. Chaque module importé par un fichier marqué 'use client' fait également partie du bundle client, que ce module contienne ou non la directive elle-même. Si le layout d’un tableau de bord principal est marqué comme module client parce qu’il contient un menu déroulant de profil, alors sa barre latérale, ses modaux, ses outils d’aide ainsi que toutes les bibliothèques lourdes qu’il importe sont également envoyés au navigateur. Pour en savoir plus sur le fait que le code exclusivement serveur ne consomme aucun octet dans le bundle, consultez comment React Server Components permet un rendering zéro-bundle.

Le piège du conteneur interactif

La erreur la plus fréquente consiste à transformer toute une page en composant client simplement parce qu’une petite partie d’elle est interactive. L’exemple ci-dessous n’a besoin de l’état que pour un interrupteur de filtre, pourtant tout le tableau de bord est marqué comme du code client :

// ❌ BAD: The entire page is pulled into the client bundle
'use client';

import { useState, useEffect } from 'react';
import { db } from '@/lib/db'; // 🚨 Build error or massive security/bundle hazard!

export default function UserDashboard() {
  const [isOpen, setIsOpen] = useState(false);
  const [data, setData] = useState(null);

  useEffect(() => {
    // You've recreated client-side waterfall fetching
    fetch('/api/user-data').then(res => res.json()).then(setData);
  }, []);

  return (
    <div className="p-8">
      <button onClick={() => setIsOpen(!isOpen)}>Toggle Filter</button>
      {isOpen && <div className="dropdown">...</div>}
      <div className="grid">
        {/* Render heavy data tables */}
      </div>
    </div>
  );
}

Deux problèmes en découlent. Importer un client de base de données dans un module client entraîne soit l’échec de la compilation, soit, pire encore, le risque d’envoyer du code et des configurations réservés au serveur vers le navigateur. De plus, le chargement des données est placé dans useEffect, ce qui fait que la page s’affiche vide avant de demander des données après l’hydratation, recréant ainsi le flux en cascade côté client que les composants serveur visaient à éliminer. Ajouter le paquet server-only à des modules tels que @/lib/db transforme cette première erreur en une erreur de compilation explicite.

Transférer l’interactivité aux éléments les plus basiques

L’état doit être placé dans la plus petite composante qui en a besoin. Ici, le bouton de commutation devient son propre module client, et tout ce qu’il affiche est transmis sous la forme de children:

// components/FilterToggle.tsx
'use client';

import { useState } from 'react';

export function FilterToggle({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="px-3 py-1.5 bg-neutral-100 rounded text-sm"
      >
        {isOpen ? 'Hide Filters' : 'Show Filters'}
      </button>
      {isOpen && <div className="mt-2">{children}</div>}
    </div>
  );
}

La page reste une composante serveur asynchrone. Elle interroge directement la base de données, rend du markup statique, et n’incorpore le bouton de commutation que là où une interaction a lieu :

// app/dashboard/page.tsx (Server Component by default)
import { db } from '@/lib/db';
import { FilterToggle } from '@/components/FilterToggle';
import { AnalyticsChart } from '@/components/AnalyticsChart';

export default async function UserDashboard() {
  // Direct DB access — zero client-side fetch waterfalls
  const metrics = await db.metrics.findMany();

  return (
    <main className="p-8">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-xl font-bold">Performance Analytics</h1>
        <FilterToggle>
          <p className="text-sm text-neutral-500">Filter options...</p>
        </FilterToggle>
      </div>

      <div className="grid grid-cols-3 gap-4">
        {/* AnalyticsChart can also remain an RSC if it doesn't need canvas/DOM APIs */}
        <AnalyticsChart data={metrics} />
      </div>
    </main>
  );
}

Le paragraphe transmis à FilterToggle est rendu sur le serveur, même s’il se trouve à l’intérieur d’une composante client. AnalyticsChart peut rester une composante serveur tant qu’il ne dépend pas d’API réservées au navigateur comme canvas ; s’il en dépend, seule cette carte a besoin de la directive.

Composant du contenu serveur à l’intérieur de coquilles client

La même technique s’applique aux layouts. Imaginons un composant client CollapsibleShell qui gère l’état ouvert et fermé, à l’instar de FilterToggle. Le layout, qui est un composant serveur, crée le flux coûteux à générer et le transmet au shell sous forme de slot :

// app/layout.tsx
import { CollapsibleShell } from '@/components/CollapsibleShell';
import { ExpensiveServerFeed } from '@/components/ExpensiveServerFeed';

export default function RootLayout() {
  return (
    <html lang="en">
      <body>
        <CollapsibleShell>
          {/* ExpensiveServerFeed runs on the server, streams HTML, and ships 0kb JS */}
          <ExpensiveServerFeed />
        </CollapsibleShell>
      </body>
    </html>
  );
}

Puisque ExpensiveServerFeed est créé dans un contexte serveur et transmis via children, sa rendu a lieu entièrement sur le serveur. React envoie son résultat rendu à travers la frontière dans le payload RSC, et le code du composant n’entre jamais dans le bundle client. La différence clé : un module client qui importe un composant l’intègre dans le bundle, tandis qu’un composant client qui reçoit des éléments déjà rendus en tant que props ne le fait pas.

Une liste de vérification avant d’ajouter la directive

  • Ce composant utilise-t-il un état, des effets, des gestionnaires d’événements ou des API du navigateur ? Si ce n’est pas le cas, conservez-le sur le serveur.
  • La partie interactive peut-elle être extraite dans un composant enfant plus petit ?
  • Le contenu généré côté serveur peut-il être transmis via children ou une autre propriété au lieu d’être importé ?
  • Toutes les propriétés qui traversent la frontière sont-elles serialisables, sans fonctions ni instances de classes à l’exception des Server Actions ?
  • Marquer ce fichier entraînerait-il l’incorporation d’une bibliothèque lourde, telle qu’un analyseur Markdown ou des outils de gestion des dates, qui pourrait rester sur le serveur ?

Points clés

  • Les composants serveur sont la solution par défaut pour une raison : conservez y la récupération de données, les outils d’analyse et le markup statique.
  • Isolez de petites parties interactives plutôt que de convertir leurs conteneurs.
  • Utilisez children et d’autres propriétés de slot pour envelopper la sortie serveur dans du comportement client sans l’envoyer.
  • Considérez 'use client' comme une frontière architecturale délibérée, et non comme un moyen de masquer une erreur de compilation. Si bien appliqué, cela permet aux bundles de rester petits et à l’affichage initial d’apparaître avec le contenu déjà en place.
  • Lectures complémentaires