Пошук уроків, статей та іншого контенту
Розмежуєте серверний і клієнтський стан та оберете відповідний інструмент для кожного типу даних.
У Next.js стан програми зазвичай поділяють на дві категорії:
server state — дані, джерелом правди для яких є сервер;
client state — дані, якими керує інтерфейс у браузері.
Це не те саме, що «дані з Server Component» і «дані з Client Component». Server Component може отримувати server state, але після передачі даних у браузер ці дані можуть стати частиною client state або кешу клієнта.
Головне питання:
Хто є джерелом правди для цих даних — сервер чи поточний інтерфейс?
Server state — це дані, які:
зберігаються в базі даних або іншому серверному сховищі;
можуть змінюватися поза межами поточного браузера;
спільно використовуються кількома користувачами або вкладками;
потребують завантаження, кешування, повторного отримання та синхронізації;
мають серверні стани loading, error, stale і success.
Приклади:
профіль поточного користувача;
список замовлень;
товари в каталозі;
коментарі до публікації;
права доступу;
сповіщення;
результати пошуку на сервері.
Якщо у клієнті є об’єкт:
const user = { name: "Olena" };це лише значення в пам’яті JavaScript.
Для server state потрібно додатково відповісти на запитання:
Коли завантажувати дані?
Що показувати під час завантаження?
Як повідомити про помилку?
Скільки часу дані вважаються актуальними?
Що робити після зміни даних?
Як уникнути дублювання запитів?
Як оновити дані після перемикання вкладки?
Як синхронізувати кілька компонентів, які використовують один ресурс?
Саме тому server state не варто моделювати лише набором useState.
Client state існує для потреб поточного інтерфейсу та зазвичай не є джерелом правди на сервері.
Приклади:
відкриття або закриття модального вікна;
активна вкладка;
вибраний елемент;
стан розгорнутої панелі;
значення чернетки форми до її надсилання;
локальний текст фільтра;
стан drag-and-drop;
поточна позиція елемента на екрані.
Такий стан:
належить конкретному інтерфейсу;
часто змінюється на кожен клік або введений символ;
не потребує синхронізації з сервером;
може зникнути після перезавантаження сторінки без втрати важливих даних.
Для client state зазвичай достатньо:
useState;
useReducer;
React Context;
Zustand або інший клієнтський store, якщо стан потрібно розділити між багатьма компонентами;
URL search params, якщо стан має бути доступним через посилання або кнопки навігації браузера.
У Next.js App Router Server Component виконується на сервері, а Client Component — у браузері.
Ця межа не визначає автоматично тип стану:
Server Component може отримати server state з бази даних.
Client Component може отримати server state через API.
Дані, передані з Server Component у Client Component, не стають client state автоматично.
useState у Client Component не перетворює серверні дані на надійне джерело правди.
Наприклад, сервер може передати початковий список товарів у Client Component. Але якщо інший користувач змінив цей список, локальна копія в браузері залишиться застарілою, якщо програма не виконає повторну синхронізацію.
У Next.js можна використовувати кілька підходів.
Підходять, коли:
дані потрібні для першого рендерингу;
їх можна отримати на сервері;
інтерактивне кешування на клієнті не потрібне;
зміни даних відбуваються рідко або сторінка повторно рендериться після мутації.
Server Component може напряму звертатися до серверного шару застосунку:
// app/products/page.tsx
import { db } from "@/lib/db";
export default async function ProductsPage() {
const products = await db.product.findMany({
orderBy: { createdAt: "desc" },
});
return (
<main>
<h1>Товари</h1>
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</main>
);
}Такий підхід не вимагає передавати дані через API до власного Server Component. Це одна з переваг серверного виконання.
fetch з кешуванням або повторною валідацієюДля HTTP-джерел можна керувати поведінкою запиту за допомогою параметрів Next.js:
const response = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
const products = await response.json();У цьому прикладі результат можна повторно використовувати протягом визначеного часу. Для даних, які не можна кешувати, застосовують відповідну динамічну стратегію, наприклад:
const response = await fetch("https://api.example.com/me", {
cache: "no-store",
});Стратегію потрібно обирати відповідно до вимог до актуальності даних. Кешування не замінює серверну модель стану — воно лише визначає, як отримані дані використовуються повторно.
Ці бібліотеки доцільні, коли server state активно використовується в Client Components і потребує:
кешу запитів;
дедуплікації однакових запитів;
повторного отримання після повернення на вкладку;
контролю staleTime;
інвалідації після мутацій;
зручних станів завантаження та помилки.
TanStack Query не робить дані локальними назавжди. Він кешує копію server state у браузері та надає механізми синхронізації з сервером.
Для простого локального стану використовуйте React:
"use client";
import { useState } from "react";
export function FiltersPanel() {
const [isOpen, setIsOpen] = useState(false);
return (
<section>
<button onClick={() => setIsOpen((value) => !value)}>
{isOpen ? "Закрити фільтри" : "Відкрити фільтри"}
</button>
{isOpen && <div>Панель фільтрів</div>}
</section>
);
}isOpen не потрібно зберігати в базі даних, кешувати чи повторно завантажувати. Це типовий client state.
Якщо стан повинен:
переживати перезавантаження — розгляньте URL або спеціальне сховище;
бути доступним із кількох незалежних частин інтерфейсу — використовуйте Context або клієнтський store;
бути доступним через посилання — зберігайте його в URL search params.
Нижче наведено мінімальний приклад для Next.js App Router:
серверний Route Handler повертає список завдань;
TanStack Query кешує server state у браузері;
локальний текст фільтра залишається client state;
зміна тексту фільтра не виконує новий запит до сервера.
Спочатку встановіть бібліотеку:
npm install @tanstack/react-query// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
type ProvidersProps = {
children: ReactNode;
};
export function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: true,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}QueryClient створюється один раз для життєвого циклу клієнтського застосунку. Його не слід створювати безпосередньо під час кожного рендерингу компонента.
// app/layout.tsx
import type { Metadata } from "next";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "Завдання",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="uk">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}layout.tsx залишається Server Component, але може рендерити Client Component Providers.
// app/api/tasks/route.ts
import { NextResponse } from "next/server";
const tasks = [
{ id: 1, title: "Оновити документацію", completed: false },
{ id: 2, title: "Перевірити pull request", completed: true },
{ id: 3, title: "Додати інтеграційні тести", completed: false },
];
export async function GET() {
return NextResponse.json(tasks);
}У реальному застосунку масив буде замінений запитом до бази даних або зовнішнього сервісу. Для прикладу важливо, що джерелом правди є серверний endpoint, а не useState у компоненті.
// app/tasks/TaskList.tsx
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
type Task = {
id: number;
title: string;
completed: boolean;
};
async function fetchTasks(): Promise<Task[]> {
const response = await fetch("/api/tasks");
if (!response.ok) {
throw new Error("Не вдалося завантажити завдання");
}
return response.json();
}
export function TaskList() {
const [filter, setFilter] = useState("");
const tasksQuery = useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
});
const filteredTasks = useMemo(() => {
const tasks = tasksQuery.data ?? [];
const normalizedFilter = filter.trim().toLowerCase();
if (!normalizedFilter) {
return tasks;
}
return tasks.filter((task) =>
task.title.toLowerCase().includes(normalizedFilter),
);
}, [filter, tasksQuery.data]);
if (tasksQuery.isPending) {
return <p>Завантаження завдань…</p>;
}
if (tasksQuery.isError) {
return (
<div>
<p>Не вдалося завантажити завдання.</p>
<button onClick={() => tasksQuery.refetch()}>Повторити</button>
</div>
);
}
return (
<section>
<label>
Фільтр:
<input
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="Назва завдання"
/>
</label>
<button
type="button"
onClick={() => tasksQuery.refetch()}
disabled={tasksQuery.isFetching}
>
{tasksQuery.isFetching ? "Оновлення…" : "Оновити"}
</button>
<ul>
{filteredTasks.map((task) => (
<li key={task.id}>
{task.title} — {task.completed ? "виконано" : "у роботі"}
</li>
))}
</ul>
</section>
);
}У цьому компоненті є два різні типи стану:
tasksQuery.data — server state, яким керує TanStack Query;
filter — client state, яким керує useState.
Якщо завдання зміняться на сервері, можна викликати refetch або інвалідувати запит після мутації. Якщо зміниться filter, серверний список не потрібно завантажувати повторно.
// app/tasks/page.tsx
import { TaskList } from "./TaskList";
export default function TasksPage() {
return (
<main>
<h1>Мої завдання</h1>
<TaskList />
</main>
);
}Сторінка є Server Component, а інтерактивний список — Client Component. Це дозволяє залишити більшу частину дерева серверною, але використовувати клієнтський кеш там, де потрібна інтерактивність.
Іноді одна й та сама сторінка має:
отримати початкові дані на сервері;
показати їх без очікування клієнтського запиту;
продовжити керувати ними через клієнтський кеш.
У такому випадку важливо не створити дві незалежні копії server state:
дані, отримані Server Component;
дані, завантажені Client Component.
Якщо ці копії не синхронізовані, інтерфейс може показувати різні значення в різних частинах сторінки.
Для інтеграції Server Components із TanStack Query використовують попереднє заповнення кешу та гідрацію. Але сам принцип залишається незмінним:
сервер надає початкове представлення server state;
клієнтський кеш продовжує ним керувати;
після мутації кеш потрібно оновити або інвалідувати.
Для простіших сторінок не обов’язково одразу використовувати гідрацію. Якщо даних небагато, можна завантажувати їх лише в Client Component або залишити сторінку повністю серверною.
Чернетка форми — це client state:
"use client";
import { useState } from "react";
export function ProfileForm() {
const [name, setName] = useState("");
return (
<form>
<label>
Ім’я:
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
</form>
);
}Поки користувач не натиснув «Зберегти», значення name є лише локальною чернеткою.
Після надсилання дані стають частиною server state. Сервер може:
відхилити їх через помилку валідації;
зберегти нормалізоване значення;
змінити пов’язані ресурси;
повернути оновлений профіль.
Тому після успішної мутації недостатньо змінити локальний об’єкт. Потрібно оновити джерело server state або інвалідувати відповідний кеш.
Поставте такі запитання:
Чи існує це значення на сервері?
Чи може його змінити інший користувач, інша вкладка або фоновий процес?
Чи потрібно повторно завантажувати або синхронізувати його?
Чи має значення переживати перезавантаження сторінки?
Чи повинно воно бути доступним через URL?
Чи належить воно лише поточному елементу інтерфейсу?
Орієнтири:
Дані з бази даних або API — server state.
Стан завантаження API-запиту — частина керування server state.
Відкрите модальне вікно — client state.
Чернетка форми — client state до моменту збереження.
Збережений профіль — server state.
Пошуковий запит у URL — навігаційний стан, який часто є кращим за локальний useState, якщо сторінку потрібно ділити посиланням.
Глобальний клієнтський store — не заміна серверному кешу.
Для складного Next.js застосунку корисно розділяти шари:
база даних;
серверні функції;
Route Handlers;
Server Components;
перевірка автентифікації та доступу.
TanStack Query або SWR;
ключі запитів;
кеш;
інвалідація;
повторне завантаження;
обробка мутацій.
useState для локального стану;
useReducer для складних локальних переходів;
Context або Zustand для спільного стану інтерфейсу;
URL для стану, який має бути відтворюваним через навігацію.
Таке розділення не дозволяє одному store перетворитися на суміш API-відповідей, стану модальних вікон, чернеток і параметрів навігації.
useStateconst [users, setUsers] = useState<User[]>([]);Такий код може бути достатнім для маленького одноразового запиту, але в складному інтерфейсі він не вирішує питання:
повторного використання даних у різних компонентах;
дедуплікації;
застарілих даних;
повторного отримання;
інвалідації після зміни.
Для активно використовуваного server state потрібен спеціалізований кеш або чітка серверна стратегія.
Клієнтський store може зберігати копію даних із сервера, але тоді вам доведеться самостійно реалізовувати:
стани завантаження;
помилки;
час актуальності;
повторні запити;
синхронізацію;
інвалідацію.
Store для стану інтерфейсу та кеш server state — це різні завдання.
Передача даних із Server Component у Client Component не гарантує, що вони залишатимуться актуальними. Пропси є лише значеннями для поточного рендерингу. Якщо ресурс може змінитися, потрібен механізм синхронізації.
Локальний фільтр уже завантаженого списку — client state. Не потрібно виконувати API-запит при кожному введеному символі, якщо фільтрація не повинна відбуватися на сервері.
Водночас для великого набору даних, серверної пагінації або складного пошуку фільтр може бути параметром server state-запиту. Це вже інша модель:
const query = useQuery({
queryKey: ["tasks", { search }],
queryFn: () => fetchTasks({ search }),
});У такій моделі search є client state або URL state, а результат запиту — server state.
Після створення, оновлення або видалення ресурсу старий кеш може залишитися в браузері. Потрібно або:
оновити кеш новим значенням;
інвалідувати відповідний query key;
повторно завантажити ресурс.
Зміна локального useState в одному компоненті не оновить автоматично інші компоненти, які показують той самий серверний ресурс.
Server state належить серверу, може бути застарілим і потребує синхронізації.
Client state належить поточному інтерфейсу та керує його поведінкою.
Server Components зручні для отримання даних на сервері та першого рендерингу.
TanStack Query або SWR підходять для server state, який активно використовується в Client Components.
useState, useReducer, Context і Zustand призначені насамперед для client state.
API-відповідь не слід бездумно копіювати в клієнтський store.
Чернетка форми та збережений на сервері профіль — різні типи стану.
Після мутацій server state потрібно оновлювати або інвалідувати кеш.
Правильне розмежування стану зменшує дублювання даних і робить поведінку застосунку передбачуваною.