open atlas
↑ К треку
Паттерны React RXP · 13 · 01

Загрузка в RSC против клиентского кэша

В App Router делай await данных в серверном компоненте для начальной загрузки на запрос без клиентского кэша; тянись за query-библиотекой (TanStack Query/SWR), только когда нужна семантика клиентского кэша — refetch, дедуп, мутации, оптимистичность, polling.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Первое, что делает любое React-приложение, — загружает данные, и это место App Router изменил сильнее всего. Старый рефлекс — кинуть useEffect, выставить loading, error, data — всё ещё работает, всё ещё компилируется и теперь обычно неправильный ответ. У тебя есть два инструмента получше, и навык уровня senior — знать, какой из них просит конкретная загрузка.

Один инструмент работает на сервере и делает await прямо в твой JSX. Другой работает на клиенте и даёт кэш с refetch, дедупом и мутациями. Они не конкуренты — у них разные задачи. Выбирай по виду загрузки, а не по привычке.

Цель

После этого урока ты можешь решить, где живёт загрузка: используй загрузку в RSC (await в серверном компоненте) для начальных данных на запрос, которым не нужен клиентский кэш; тянись за библиотекой клиентского кэша (TanStack Query или SWR), когда нужны refetch, дедуп запросов, фоновая ревалидация, polling, мутации или оптимистичные обновления. Ты можешь написать оба, передать серверные данные в клиентский кэш как начальные и объяснить, почему загрузка в useEffect — это режим отказа, когда доступен любой из первых двух.

1

Для начальных данных на запрос грузи на сервере: await прямо в серверном компоненте. В App Router компонент по умолчанию серверный. Он может быть async, сделать await твоих данных и отрендерить результат — загрузка происходит во время запроса, до того как HTML дойдёт до браузера, без спиннера загрузки, без useEffect и без отправки клиентского JavaScript под эту загрузку.

// app/orders/page.tsx — серверный компонент (без "use client")
async function OrdersPage() {
  const orders = await getOrders();         // выполняется на сервере, на запрос
  return (
    <ul>
      {orders.map((o) => <li key={o.id}>{o.total}</li>)}
    </ul>
  );
}
export default OrdersPage;

Клиентского кэша здесь нет и не нужно: данные вычисляются заново на каждый запрос и встраиваются в HTML. Это вариант по умолчанию, к которому стоит тянуться первым.

2

Тянись за библиотекой клиентского кэша, когда нужна семантика клиентского кэша, а не просто данные. RSC await даёт значение один раз. Query-библиотека (TanStack Query, SWR) даёт кэш по ключу запроса: она дедуплицирует одновременные запросы, мгновенно отдаёт устаревшие данные, ревалидируя в фоне, делает refetch на фокус/переподключение, опрашивает по интервалу и предоставляет loading/error/isFetching, не заставляя тебя их разводить руками. Ничего из этого нет в одноразовом серверном await.

"use client";
import { useQuery } from "@tanstack/react-query";

function Orders() {
  const { data, isPending, error, refetch } = useQuery({
    queryKey: ["orders"],
    queryFn: getOrders,
    staleTime: 30_000,            // отдавать из кэша 30с до ревалидации
    refetchOnWindowFocus: true,   // фоновое обновление, когда вкладка снова в фокусе
  });
  if (isPending) return <Spinner />;
  if (error) return <Error onRetry={refetch} />;
  return <OrderList orders={data} />;
}

Если два компонента просят ["orders"], библиотека делает один запрос и делит его между ними. Этот дедуп + кэш и есть весь смысл подключать клиентскую библиотеку.

3

Мутации, оптимистичные обновления и polling — однозначно территория клиентского кэша. Серверный компонент рендерится один раз и исчезает — он не может перезапуститься по клику кнопки. В тот момент, когда UI должен изменить данные и отразить это, тебе нужен клиентский кэш, чтобы мутация могла инвалидировать или пропатчить закэшированный запрос и вызвать перерисовку. useMutation из TanStack (или mutate из SWR) снимает снимок кэша, применяет оптимистичное значение и откатывается при ошибке.

"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";

function ToggleFavorite({ id }: { id: string }) {
  const qc = useQueryClient();
  const m = useMutation({
    mutationFn: () => toggleFavorite(id),
    onMutate: async () => {
      await qc.cancelQueries({ queryKey: ["orders"] });
      const prev = qc.getQueryData(["orders"]);
      qc.setQueryData(["orders"], optimisticToggle(id));  // мгновенный UI
      return { prev };
    },
    onError: (_e, _v, ctx) => qc.setQueryData(["orders"], ctx?.prev), // откат
    onSettled: () => qc.invalidateQueries({ queryKey: ["orders"] }),  // сверка
  });
  return <button onClick={() => m.mutate()}>★</button>;
}

Polling (refetchInterval) и refetchOnReconnect — та же история: непрерывная, управляемая клиентом свежесть, которую RSC попросту дать не может.

4

Загрузка в useEffect — это режим отказа всякий раз, когда доступен RSC или query-библиотека. Классический useEffect(() => { fetch().then(setData) }, []) плохо переизобретает библиотеку кэша: в нём гонка (устаревший ответ может прийти после более нового и перезаписать его, если не вести флаг ignore), он никогда не дедуплицирует (каждое монтирование грузит заново), у него нет кэша (ушёл и вернулся — грузим с нуля) и он заставляет каждый раз вручную разводить loading/error. Собственная документация React помечает загрузку данных в эффектах как то, что обычно не нужно.

// ❌ антипаттерн, ради вывода которого существует этот урок
useEffect(() => {
  let ignore = false;                       // костыль против гонки
  setLoading(true);
  fetch(`/api/orders`)
    .then((r) => r.json())
    .then((d) => { if (!ignore) setData(d); })
    .catch((e) => { if (!ignore) setError(e); })
    .finally(() => { if (!ignore) setLoading(false); });
  return () => { ignore = true; };
}, []);                                      // ни кэша, ни дедупа, всё руками

Используй эффект для загрузки, только когда у тебя нет ни серверного компонента (например, чистый SPA без RSC), ни query-библиотеки — да и тогда query-библиотека обычно дешевле по суммарной стоимости.

Разбор примера

Экран заказов, которому нужны и серверно-быстрая начальная отрисовка, и клиентские refetch + мутация. Наивная клиентская версия грузит в эффекте и платит за это на каждом визите.

// ❌ до: только клиент, загрузка в эффекте — медленная первая отрисовка, гонки, без кэша
"use client";
function Orders() {
  const [data, setData] = useState<Order[] | null>(null);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    let ignore = false;
    getOrders().then((d) => !ignore && (setData(d), setLoading(false)));
    return () => { ignore = true; };
  }, []);
  if (loading) return <Spinner />;
  return <OrderList orders={data!} />;
}

Senior-расположение грузит один раз на сервере для мгновенной первой отрисовки, а затем передаёт этот результат в TanStack Query как начальные данные, так что клиент владеет refetch, мутациями и polling — без второго спиннера загрузки и без водопада.

// ✅ после: серверный компонент делает await начальных данных, дальше — клиентский кэш
// app/orders/page.tsx (серверный компонент)
async function OrdersPage() {
  const initial = await getOrders();          // быстро, на запрос, в HTML
  return <OrdersClient initialOrders={initial} />;
}

// orders-client.tsx (клиентский компонент)
"use client";
function OrdersClient({ initialOrders }: { initialOrders: Order[] }) {
  const { data } = useQuery({
    queryKey: ["orders"],
    queryFn: getOrders,
    initialData: initialOrders,               // без клиентской вспышки загрузки
    staleTime: 30_000,
    refetchOnWindowFocus: true,               // семантика клиентского кэша
  });
  return <OrderList orders={data} />;
}

Серверный await владеет первым рендером; query-библиотека владеет каждым рендером после. Вот этот шов: RSC для начальных данных, клиентский кэш для живого интерактивного жизненного цикла — а загрузка в useEffect не появляется нигде.

Почему это работает

Почему предпочесть серверный await для начальных данных вместо того, чтобы всегда использовать query-библиотеку на клиенте? Три причины. Он отправляет ноль JavaScript под загрузку и ноль водопада для первой отрисовки — данные в HTML, так что нет цикла спиннер→загрузка→рендер. Он держит секреты и тяжёлые запросы на сервере (никакого открытого API-поверхности, никакого клиентского бандла под слой данных). И он проще: один await, без ключей запроса, без провайдера — пока тебе действительно не понадобится семантика кэша. Query-библиотека — это инструмент, который ты добавляешь, когда взаимодействие того требует, а не вариант по умолчанию для чтения данных.

Частая ошибка

Самая частая перекоррекция — использовать серверный компонент для данных, по сути интерактивных: живой дашборд, который должен опрашивать, список с оптимистичными переключателями, поиск, который грузит заново по мере набора. RSC может отрендерить эти данные один раз, но не может делать refetch, дедуп, мутацию или polling; попытка его заставить ведёт к полным перезагрузкам маршрута или, хуже, к контрабандному useEffect. Правило режет в обе стороны: не тянись за query-библиотекой ради чтения статических начальных данных и не пытайся заставить RSC делать работу клиентского кэша. Подбирай инструмент по тому, нужен ли данным живой кэш после первой отрисовки.

Проверь себя
Викторина

Виджет показывает живой счётчик заказов, который должен автообновляться каждые 10 секунд и мгновенно меняться, когда пользователь жмёт «отметить выполненным». В приложении на App Router где живёт эта загрузка?

Итог

Где живёт загрузка, решают два вопроса. Это начальные данные на запрос, которым не нужен клиентский кэш? Тогда сделай await в async серверном компоненте — он выполняется на сервере, попадает в HTML, не отправляет JS под загрузку и держит секреты только на сервере. Нужна ли семантика кэша — refetch, дедуп запросов, фоновая ревалидация, polling, мутации, оптимистичные обновления? Тогда используй библиотеку клиентского кэша (TanStack Query или SWR), ключ — query key, при желании засеянную начальными данными из RSC, чтобы первая отрисовка осталась быстрой. Эти двое компонуются: серверный await владеет первым рендером, клиентский кэш — интерактивным жизненным циклом после него. Режим отказа — это загрузка в useEffect, когда доступен любой из них: она плохо переизобретает библиотеку кэша — гонки, никакого дедупа, нет кэша, ручная разводка loading/error. Выбирай по виду загрузки, а не по привычке: начальные данные → сервер; живые данные → клиентский кэш; загрузка в эффекте → крайнее средство.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.