Головна / Статті / Правильне малювання межі «use client» у Next.js App Router

Правильне малювання межі «use client» у Next.js App Router

Дізнайтеся, що насправді позначає директива ‘use client’, чому позначення контейнерів збільшує розмір пакетів, та як листкові компоненти та слоти дочерніх елементів зберігають обробку на сервері.

1190 слів

Якщо відкрити багато проєктів App Router, ви побачите, що майже у кожному компоненті на початку знаходиться 'use client'. Ця звичка сформується швидко: ви додаєте onClick або useState для випадкового списку, процес збірки скаржиться, а директива усуває цю помилку. Якщо повторити це кілька десятків разів, весь код потрапляє до бандлу браузера, що створює односторінковий додаток із зайвими складовими. У цьому посібнику пояснюється, що насправді робить ця директива та як її використовувати, щоб обробка на сервері залишалася на сервері, а лише справді інтерактивні частини передавали JavaScript.

Що насправді позначає директива

Назва натякає на «виконувати це лише в браузері», але це не є його сенсом. 'use client' визначає межу модуля: файл, де закінчується граф серверних модулів та починається пакет клієнта. Клієнтські компоненти все одно попередньо обробляються у форматі HTML на сервері; крім того, вони ініціалізуються у браузері.

[ 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!)

Наслідок, який створює проблеми командам, є транзитивним. Кожен модуль, імпортований файлом з позначкою 'use client', також стає частиною клієнтського пакету, незалежно від того, чи містить він сам цю директиву. Якщо основна структура панелі керування позначена як клієнтський модуль через наявність спадного списку з профілями, то її бічна панель, модальні вікна, допоміжні елементи та всі важкі бібліотеки, які вона імпортує, також потрапляють у браузер. Щоб детальніше дізнатися, чому код, який працює лише на сервері, не збільшує розмір пакета, перегляньте як React Server Components досягають нульового розміру пакета під час відображення.

Пастка інтерактивного контейнера

Найпоширеніша помилка — це перетворення всієї сторінки на клієнтський компонент лише через те, що одна його невелика частина є інтерактивною. У наведеному нижче прикладі стан потрібен лише для перемикання фільтра, проте весь панель керування позначається як клієнтський код:

// ❌ 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>
  );
}

Це призводить до двох проблем. Імпорт клієнта бази даних у клієнтський модуль або призводить до невдачі під час збирання проекту, або, що гірше, створює ризик перенесення коду та конфігурації, призначених лише для сервера, у браузер. Крім того, завантаження даних відбувається через useEffect, тож сторінка відображається порожньою, а потім запитує дані після гідратації, що повторює процес обробки даних на клієнтській стороні, який мали усунути серверні компоненти. Додавання пакета server-only до модулів на кшталт @/lib/db перетворює цю першу помилку на чітку помилку під час збирання проекту.

Перенесіть інтерактивність на нижчі рівні

Дані мають знаходитися у найменшому компоненті, який їх потребує. Тут перемикач стає окремим клієнтським модулем, а все, що він відображає, передається як 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>
  );
}

Сторінка залишається асинхронним серверним компонентом. Він безпосередньо запитує базу даних, генерує статичний маркап та вбудовує перемикач лише там, де відбувається взаємодія:

// 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>
  );
}

Абзац, переданий до FilterToggle, генерується на сервері, хоча він знаходиться всередині клієнтського компонента. AnalyticsChart може залишатися серверним компонентом, доки він не буде залежати від API, доступних лише у браузері, таких як canvas; якщо ж залежить, то лише цей чарт потребує відповідної директиви.

Складання серверного контенту всередині клієнтських оболонок

Та сама техніка може бути застосована до макетів. Уявімо собі клієнтський компонент CollapsibleShell, який керує станом відкритості та закритості, подібно до FilterToggle. Макет, що є серверним компонентом, створює ресурс із складною обробкою та передає його шелу у вигляді слота:

// 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>
  );
}

Оскільки ExpensiveServerFeed створюється в серверному середовищі та передається як children, його відображення відбувається повністю на сервері. React надсилає отриманий результат через межу у вантажі RSC, і код компонента ніколи не потрапляє до клієнтського пакету. Ключова відмінність: клієнтський модуль, який імпортує компонент, включає його до пакету, тоді як клієнтський компонент, який отримує вже оброблені елементи як пропси, цього не робить.

Перелік перевірок перед додаванням директиви

  • Чи використовує цей компонент стан, ефекти, обробники подій чи API браузера? Якщо ні, залиште його на сервері.
  • Чи можна вийняти інтерактивну частину у менший дочірній компонент?
  • Чи можна передати вміст, отриманий на сервері, через children або інший проп замість імпорту?
  • Чи всі пропи, які перетинають межу, є серіалізованими, без функцій чи екземплярів класів, окрім Server Actions?
  • Чи призведе позначення цього файлу до включення важкої бібліотеки, такої як парсер Markdown чи інструменти для роботи з датами, яка могла б залишитися на сервері?

Ключові висновки

  • Серверні компоненти є стандартом не просто так; тримайте там отримання даних, інструменти для парсингу та статичний маркап.
  • Ізолюйте невеликі інтерактивні елементи замість того, щоб перетворювати їхні контейнери.
  • Використовуйте children та інші властивості слотів, щоб обгорнути результати роботи сервера клієнтською логікою, не передаючи їх безпосередньо.
  • Розглядайте 'use client' як свідому архітектурну межу, а не як спосіб приховати помилку під час збірки. Якщо це зроблено правильно, бандли залишаються невеликими, а вміст швидко відображається.