Загрузка в RSC против клиентского кэша
В App Router делай await данных в серверном компоненте для начальной загрузки на запрос без клиентского кэша; тянись за query-библиотекой (TanStack Query/SWR), только когда нужна семантика клиентского кэша — refetch, дедуп, мутации, оптимистичность, polling.
Первое, что делает любое React-приложение, — загружает данные, и это место App Router изменил сильнее всего. Старый рефлекс — кинуть useEffect, выставить loading, error, data — всё ещё работает, всё ещё компилируется и теперь обычно неправильный ответ. У тебя есть два инструмента получше, и навык уровня senior — знать, какой из них просит конкретная загрузка.
Один инструмент работает на сервере и делает await прямо в твой JSX. Другой работает на клиенте и даёт кэш с refetch, дедупом и мутациями. Они не конкуренты — у них разные задачи. Выбирай по виду загрузки, а не по привычке.
После этого урока ты можешь решить, где живёт загрузка: используй загрузку в RSC (await в серверном компоненте) для начальных данных на запрос, которым не нужен клиентский кэш; тянись за библиотекой клиентского кэша (TanStack Query или SWR), когда нужны refetch, дедуп запросов, фоновая ревалидация, polling, мутации или оптимистичные обновления. Ты можешь написать оба, передать серверные данные в клиентский кэш как начальные и объяснить, почему загрузка в useEffect — это режим отказа, когда доступен любой из первых двух.
Для начальных данных на запрос грузи на сервере: 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. Это вариант по умолчанию, к которому стоит тянуться первым.
Тянись за библиотекой клиентского кэша, когда нужна семантика клиентского кэша, а не просто данные. 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"], библиотека делает один запрос и делит его между ними. Этот дедуп + кэш и есть весь смысл подключать клиентскую библиотеку.
Мутации, оптимистичные обновления и 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 попросту дать не может.
Загрузка в 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.