Server-side prefetching в TanStack Query — механизм предварительной загрузки данных на сервере до момента отправки HTML клиенту. Основная цель — исключить повторные запросы после гидратации приложения и обеспечить мгновенное отображение готовых данных на клиентской стороне.
Подход особенно важен в следующих сценариях:
Без prefetching клиент обычно выполняет следующую последовательность:
Это приводит к:
Server prefetching переносит загрузку данных на сервер:
Архитектура server prefetching строится вокруг трёх ключевых API:
prefetchQuerydehydrateHydrationBoundary или HydrateПолный жизненный цикл выглядит так:
Server:
QueryClient
↓
prefetchQuery()
↓
cache filled
↓
dehydrate()
↓
serialized state
Client:
hydrate()
↓
cache restored
↓
useQuery()
npm install @tanstack/react-query
Для React:
import {
QueryClient,
QueryClientProvider,
HydrationBoundary,
dehydrate,
useQuery,
} from "@tanstack/react-query";
Во время SSR нельзя использовать глобальный singleton QueryClient.
Неправильный вариант:
const queryClient = new QueryClient();
Причины:
Правильный подход — новый QueryClient на каждый HTTP-request.
export async function renderPage() {
const queryClient = new QueryClient();
return queryClient;
}
Основной метод серверной загрузки:
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
После выполнения данные оказываются в кеше.
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
Особенности:
const posts = await queryClient.fetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
Особенности:
async function fetchPosts() {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts"
);
if (!response.ok) {
throw new Error("Request failed");
}
return response.json();
}
import {
QueryClient,
dehydrate,
} from "@tanstack/react-query";
export async function serverRender() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return {
dehydratedState: dehydrate(queryClient),
};
}
import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
function Posts() {
const { data } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return (
<div>
{data.map(post => (
<div key={post.id}>
{post.title}
</div>
))}
</div>
);
}
export default function App({ dehydratedState }) {
const [queryClient] = React.useState(
() => new QueryClient()
);
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
</QueryClientProvider>
);
}
dehydrate() сериализует Query Cache.
const dehydratedState = dehydrate(queryClient);
Результат:
{
queries: [...],
mutations: [...]
}
В dehydrated state входят:
Hydration восстанавливает кеш на клиенте.
<HydrationBoundary state={dehydratedState}>
<App />
</HydrationBoundary>
После гидратации:
useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
не выполняет немедленный запрос, потому что данные уже существуют в кеше.
Даже после hydration запрос может автоматически перезапуститься.
Причина — stale state.
По умолчанию:
staleTime: 0
Это означает:
данные считаются устаревшими сразу после получения
Поэтому клиент выполняет background refetch.
Для SSR почти всегда нужен staleTime.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
Теперь:
Server-side prefetching обычно используется сразу для нескольких query.
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
}),
queryClient.prefetchQuery({
queryKey: ["users"],
queryFn: fetchUsers,
}),
queryClient.prefetchQuery({
queryKey: ["comments"],
queryFn: fetchComments,
}),
]);
Параллельная загрузка существенно ускоряет SSR.
Иногда запросы зависят друг от друга.
const user = await queryClient.fetchQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
await queryClient.prefetchQuery({
queryKey: ["projects", user.id],
queryFn: () => fetchProjects(user.id),
});
Для бесконечных списков используется:
await queryClient.prefetchInfiniteQuery({
queryKey: ["feed"],
queryFn: fetchFeed,
initialPageParam: 0,
});
const {
data,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["feed"],
queryFn: fetchFeed,
initialPageParam: 0,
getNextPageParam: lastPage => {
return lastPage.nextCursor;
},
});
Hydration корректно восстанавливает:
Не все запросы необходимо сериализовывать.
dehydrate(queryClient, {
shouldDehydrateQuery: query => {
return query.queryKey[0] !== "admin";
},
});
Это помогает:
Иногда ошибки не должны попадать в клиентский state.
dehydrate(queryClient, {
shouldDehydrateQuery: query => {
return query.state.status === "success";
},
});
Крупные SSR-приложения могут сериализовывать мегабайты данных.
Проблемы:
Частые причины:
await queryClient.prefetchQuery({
queryKey: ["hero"],
queryFn: fetchHero,
});
Не стоит prefetch:
useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
select: data => {
return data.slice(0, 10);
},
});
Плохо:
["dashboard"]
Лучше:
["dashboard", "stats"]
["dashboard", "feed"]
["dashboard", "notifications"]
Классический SSR:
export async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
В App Router server components могут выполнять prefetch напрямую.
import {
QueryClient,
dehydrate,
HydrationBoundary,
} from "@tanstack/react-query";
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return (
<HydrationBoundary
state={dehydrate(queryClient)}
>
<Posts />
</HydrationBoundary>
);
}
"use client";
function Posts() {
const { data } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return (
<div>
{data.map(post => (
<div key={post.id}>
{post.title}
</div>
))}
</div>
);
}
React 18 поддерживает streaming SSR.
TanStack Query совместим со streaming, но необходимо учитывать:
useSuspenseQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
При корректном server prefetch Suspense fallback не отображается, потому что данные уже существуют в кеше.
Ошибка prefetch не должна разрушать весь SSR-рендер.
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
Если запрос завершится ошибкой:
Иногда SSR необходимо остановить.
try {
await queryClient.fetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
} catch (error) {
return {
notFound: true,
};
}
await queryClient.prefetchQuery({
queryKey: ["session"],
queryFn: fetchSession,
});
const session = queryClient.getQueryData([
"session",
]);
if (session) {
await queryClient.prefetchQuery({
queryKey: ["profile", session.userId],
queryFn: () => fetchProfile(session.userId),
});
}
После prefetch данные доступны синхронно.
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
const posts = queryClient.getQueryData([
"posts",
]);
Server-side prefetching хорошо сочетается с:
Во время SSR необходимо учитывать cookies и headers.
await queryClient.prefetchQuery({
queryKey: ["me"],
queryFn: () => fetchMe(req.headers.cookie),
});
Категорически нельзя:
const globalClient = new QueryClient();
Иначе:
В RSC сервер может загружать данные без useQuery.
Однако TanStack Query остаётся полезен для:
Ошибка hydration mismatch возникает, когда:
Плохо:
queryKey: ["posts", new Date()]
Плохо:
queryKey: ["posts", Math.random()]
Правильно:
queryKey: ["posts", page]
Dehydration использует JSON serialization.
Проблемы возникают с:
{
createdAt: new Date()
}
После hydration:
createdAt // string
Лучше сериализовать вручную.
return posts.map(post => ({
...post,
createdAt: post.createdAt.toISOString(),
}));
Частая стратегия:
staleTime: 5 * 60 * 1000
Поведение:
На сервере gcTime обычно менее важен, потому что
QueryClient живёт недолго.
Но при long-running SSR environments настройки становятся актуальными:
new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60,
},
},
});
Некоторые фреймворки позволяют prefetch route data заранее.
TanStack Query может подготавливать кеш до навигации:
queryClient.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
});
В крупных приложениях часть запросов загружается глобально.
Пример:
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["session"],
queryFn: fetchSession,
}),
queryClient.prefetchQuery({
queryKey: ["menu"],
queryFn: fetchMenu,
}),
]);
SSR-prefetch особенно важен для:
Без prefetch поисковый робот может увидеть только loading state.
Слишком большой prefetch ухудшает TTFB.
Баланс между:
Плохой SSR:
await queryClient.prefetchQuery(...);
await queryClient.prefetchQuery(...);
await queryClient.prefetchQuery(...);
Возникает waterfall.
Лучше:
await Promise.all([
queryClient.prefetchQuery(...),
queryClient.prefetchQuery(...),
queryClient.prefetchQuery(...),
]);
TanStack Query автоматически дедуплицирует одинаковые запросы.
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
}),
queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
}),
]);
Реальный network request будет один.
Современная альтернатива:
await queryClient.ensureQueryData({
queryKey: ["posts"],
queryFn: fetchPosts,
});
Поведение:
Опасность возникает при:
На production SSR-системы обычно используют:
Request
↓
Create QueryClient
↓
Parallel prefetch
↓
dehydrate()
↓
HTML + state
↓
Browser
↓
hydrate()
↓
Instant cache access
↓
Background refetch