Client state vs server state в React: где проходит граница

Разбираем client state vs server state в React: где хранить данные, когда нужен query cache, какие ошибки ломают production и что отвечать на интервью.

08 апреля 2026 г.18 минLexicon Team

Введение

Тема client state vs server state всплывает не потому, что команда любит спорить о библиотеках. Обычно это симптом более приземленной проблемы: данные лежат не в том слое, один и тот же источник истины дублируется в нескольких местах, а интерфейс начинает существовать независимо от сервера. В результате на экране появляются устаревшие данные, лишние перерендеры, гонки после мутаций и код, который страшно трогать. В отдельном материале про state management в React хорошо видно, что вопрос почти всегда архитектурный, а не библиотечный.

Под client state здесь будем понимать состояние, источником истины для которого является сам клиент: открыт ли сайдбар, какой таб активен, что набрано в инпуте, какие строки выделены в таблице, в каком шаге находится мастер. Server state устроен иначе: его владелец не React-компонент, а сервер. Клиент только читает снимок этих данных, кэширует его, переиспользует, обновляет и синхронизирует после изменений.

Проблема начинается в тот момент, когда обе природы состояния смешиваются. Команда берет ответ сервера, кладет его в локальный store, потом редактирует его как обычный client state, а затем добавляет еще один refetch, чтобы "на всякий случай" синхронизироваться. В демо это выглядит рабочим. В production такой подход быстро создаёт рассинхронизацию, лишний сетевой шум и труднообъяснимые баги.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Как отличать client state от server state без гадания

Самый полезный вопрос не "в какой библиотеке это хранить", а "кто владеет этими данными". Если значение меняется из-за действий пользователя внутри текущего UI и не обязано совпадать с внешним источником, это почти всегда client state. Если же данные приходят по сети, могут устаревать независимо от интерфейса и должны переживать refetch, retry и invalidation, это server state.

Ниже короткая проверка, которая обычно снимает половину споров:

КритерийClient stateServer state
Источник истиныБраузер и текущее приложениеСервер
Кто меняет данныеПользовательский UI и локальная логикаБэкенд, другие пользователи, фоновые процессы
Нужен ли cache lifecycleРедкоПочти всегда
Может ли значение устареть без действий пользователяОбычно нетДа
Нужны ли refetch и invalidationОбычно нетДа
Нужна ли дедупликация запросовНетДа
Типичные примерымодалка, фильтр, драфт формыпрофиль, список заказов, права доступа, результаты поиска

Эта граница особенно важна на собеседовании. Интервьюер редко спрашивает определение ради определения. Обычно он проверяет, понимаете ли вы, почему selectedTab и ordersQuery.data нельзя проектировать одинаковым образом. Если ответ начинается с выбора между Redux и Zustand, а не с типа состояния, баллы обычно теряются уже в первой минуте.

Архитектурный разбор: как выглядит здоровое разделение слоев

Представим экран управления заказами. На нем есть серверный список заказов, фильтры в URL, локально открытая панель деталей, оптимистическое изменение статуса заказа и форма комментария. Если все это свалить в один глобальный store, получится монолит с размытыми границами ответственности. Если все разнести по локальным useState, появятся дубли и несогласованные переходы.

Рабочая схема обычно выглядит так:

  • query-слой хранит server state: список заказов, детали заказа, статусы загрузки, cache и invalidation;
  • URL хранит shareable-состояние: фильтры, сортировку, пагинацию;
  • локальный client state хранит детали интерфейса: открыт ли drawer, какая секция развернута, какой элемент сейчас в hover-состоянии;
  • форма держит собственный черновик, пока пользователь редактирует данные;
  • после успешной мутации query-слой либо инвалидирует запрос, либо применяет контролируемое optimistic update.

Именно здесь полезно понимать, когда компонент действительно перерендеривается. Неверная граница состояния почти всегда проявляется не только в сложности кода, но и в радиусе обновлений: изменение локального фильтра вдруг заставляет обновляться полэкрана, а ответ сервера копируется в три места и теряет согласованность.

Поток данных и точки отказа

Возьмем сценарий: пользователь меняет фильтр, открывает карточку заказа и меняет статус.

  1. Изменение фильтра обновляет URL.
  2. Query-слой видит новый query key и перезапрашивает список.
  3. UI продолжает жить на текущем снимке данных или показывает controlled loading state.
  4. Пользователь открывает карточку. Это client state: локальный выбор сущности и открытие панели.
  5. Мутация статуса уходит на сервер.
  6. После ответа query-слой либо делает invalidation, либо обновляет cache по точному ключу.

Слабые места у этой схемы тоже понятны. Если черновик формы напрямую связан с query-данными, refetch может перезаписать пользовательский ввод. Если после мутации нет явной invalidation-стратегии, список и карточка начнут показывать разные значения. Если server state дублируется в store, команда получит второй cache без четкой политики управления.

Код-пример 1: плохое смешение client state и server state

import { useEffect, useState } from "react";

type User = {
  id: string;
  name: string;
  role: string;
};

export function UsersPage() {
  const [users, setUsers] = useState<User[]>([]);
  const [selectedRole, setSelectedRole] = useState("all");

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then((data: User[]) => setUsers(data));
  }, []);

  const filteredUsers =
    selectedRole === "all"
      ? users
      : users.filter((user) => user.role === selectedRole);

  async function promote(userId: string) {
    await fetch(`/api/users/${userId}/promote`, { method: "POST" });

    setUsers((current) =>
      current.map((user) =>
        user.id === userId ? { ...user, role: "admin" } : user
      )
    );
  }

  return (
    <>
      <RoleFilter value={selectedRole} onChange={setSelectedRole} />
      <UsersTable users={filteredUsers} onPromote={promote} />
    </>
  );
}

В небольшом приложении этот код может существовать долго. Но проблема уже внутри конструкции. selectedRole здесь нормальный client state. А users не локальное состояние компонента по своей природе, а снимок server state. Как только появляется второй экран, refetch по таймеру, обновление в соседней вкладке или редактирование пользователя из модалки, модель перестаёт работать корректно.

Типичный симптом в production: после мутации UI показывает одно значение, а следующий запрос возвращает другое. Еще хуже, если список пользователей уже где-то используется повторно. Тогда один компонент держит старый локальный снимок, другой успел заново загрузиться, и команда начинает чинить архитектуру точечными синхронизациями.

Почти тот же разговор есть в теме React data fetching паттернов, но именно здесь важно увидеть границу: локальный фильтр живет рядом с компонентом, а данные сервера должны жить в слое, который управляет серверным состоянием.

Код-пример 2: разделение слоев через query cache

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";

type User = {
  id: string;
  name: string;
  role: string;
};

export function UsersPage() {
  const [selectedRole, setSelectedRole] = useState("all");
  const queryClient = useQueryClient();

  const usersQuery = useQuery({
    queryKey: ["users"],
    queryFn: async () => {
      const res = await fetch("/api/users");
      if (!res.ok) throw new Error("Request failed");
      return (await res.json()) as User[];
    },
    staleTime: 30_000,
  });

  const promoteMutation = useMutation({
    mutationFn: async (userId: string) => {
      const res = await fetch(`/api/users/${userId}/promote`, {
        method: "POST",
      });
      if (!res.ok) throw new Error("Mutation failed");
      return userId;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  const users = usersQuery.data ?? [];
  const filteredUsers =
    selectedRole === "all"
      ? users
      : users.filter((user) => user.role === selectedRole);

  return (
    <>
      <RoleFilter value={selectedRole} onChange={setSelectedRole} />
      <UsersTable
        users={filteredUsers}
        onPromote={(userId) => promoteMutation.mutate(userId)}
      />
    </>
  );
}

Здесь selectedRole остался client state, потому что это состояние интерфейса. А список пользователей живет как server state: с query key, политикой устаревания, отдельной мутацией и invalidation. Код стал не просто "моднее". Он стал честнее относительно природы данных.

У такого подхода есть цена: нужно держать в голове query lifecycle и не превращать query cache в универсальный доменный store. Но это зрелый компромисс. В материале React Query под капотом это особенно заметно: query-библиотека полезна не тем, что умеет делать fetch, а тем, что берет на себя жизненный цикл server state.

Когда server state все же попадает в client state

Полное разделение не означает, что query-данные никогда не переходят в client state. Это происходит, когда из серверного снимка вы создаете локальный черновик для редактирования. Например, форма профиля стартует с данных пользователя, но после инициализации живет как draft state. И вот тут важна формулировка: вы не переносите ownership данных в клиент навсегда, а создаете локальную рабочую копию для ограниченного сценария.

import { useEffect, useState } from "react";

type Profile = {
  name: string;
  bio: string;
};

export function ProfileForm({ profile }: { profile: Profile }) {
  const [draft, setDraft] = useState(profile);

  useEffect(() => {
    setDraft(profile);
  }, [profile]);

  return (
    <form>
      <input
        value={draft.name}
        onChange={(e) => setDraft((s) => ({ ...s, name: e.target.value }))}
      />
      <textarea
        value={draft.bio}
        onChange={(e) => setDraft((s) => ({ ...s, bio: e.target.value }))}
      />
    </form>
  );
}

Именно в таких местах кандидаты часто отвечают слишком обобщённо. Проблема не в самом наличии локальной копии, а в правилах синхронизации. Если profile может обновиться фоновым refetch во время редактирования, слепой setDraft(profile) затрет ввод пользователя. Значит, нужны либо dirty-check, либо изоляция формы от фоновой синхронизации до save/cancel, либо более явная стратегия merge.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks

Начать

Риски и сложности в продакшене: где команды чаще всего ломают эту границу

Копируют server state в глобальный store "для удобства"

Так появляется второй cache, но без staleTime, invalidation и понятного query lifecycle. Первые признаки видны в логике: после каждой мутации добавляется ручной dispatch, а потом еще и refetch, чтобы не рисковать. Итогом становится двойная синхронизация и расхождение данных на экранах.

Хранят client state в query cache

Иногда в query-слой кладут состояние фильтров, открытых табов или состояние мастера просто потому, что "там уже удобно подписываться". Это обратная крайность. Query cache начинает хранить данные, которые вообще не принадлежат серверу, и модель становится трудночитаемой.

Не разделяют server snapshot и form draft

Это одна из самых дорогих ошибок. Пользователь редактирует форму, приходит refetch, UI перерисовывается, и часть полей откатывается. В аналитике это всплывает как странные повторные сохранения, брошенные формы и жалобы на нестабильность: "иногда инпуты живут своей жизнью". Для смежной темы полезно посмотреть материал про controlled vs uncontrolled компоненты: он хорошо показывает, где теряется контроль над источником истины.

Лечат проблему мемоизацией вместо переопределения границ

Когда смешанные слои начинают вызывать широкий круг обновлений, команда добавляет memo, useMemo и useCallback. Иногда это помогает локально, но не устраняет архитектурную причину. Если один тип состояния лежит не в своем слое, оптимизация становится косметикой. Это хорошо видно в статье про мемоизацию в React: оптимизация полезна после диагноза, а не вместо него.

Разбор производительности

Главное узкое место здесь не в одном конкретном хуке, а в цене неправильного управления данными.

  • По latency страдает UI после мутаций, когда экран ждет лишний refetch или синхронизирует два источника истины.
  • По throughput страдает приложение, если одни и те же данные тянут несколько компонентов без дедупликации.
  • По memory страдает клиент, когда список сущностей одновременно лежит в query cache, глобальном store и локальных копиях.
  • По cpu страдает дерево, если локальное UI-состояние поднято слишком высоко и вызывает каскад ререндеров.

Оптимизация здесь оправдана тогда, когда проблема уже измерена: есть профиль ререндеров, видно дублирующиеся запросы, понятен радиус обновлений. Если измерения нет, разговор про useMemo обычно преждевременный. Для диагностики лучше сначала идти в разбор React performance profiling, а уже потом решать, переносить ли состояние, делить ли provider или менять ли query-границу.

Практики, которые обычно работают лучше всего

  • Сначала классифицируйте состояние по источнику истины, а потом выбирайте библиотеку.
  • Держите server state в слое, который умеет cache, retry, background refetch и invalidation.
  • Не копируйте query-данные в client store без четкого сценария и срока жизни этой копии.
  • Для формы явно решайте, где snapshot сервера, а где draft пользователя.
  • URL-состояние не прячьте в памяти приложения, если его нужно разделять между экранами, восстанавливать и сохранять после перезагрузки.
  • Проверяйте радиус обновлений: если локальный toggle вызывает ререндер большого поддерева, проблема обычно не в отсутствии мемоизации.

Частые ошибки

  • Называть любые данные в React просто "state", не разделяя их природу.
  • Говорить, что server state "это тот же store, только с fetch".
  • Считать, что invalidation можно заменить ручным setState.
  • Держать серверный снимок и черновик формы в одном объекте.
  • Пытаться решить архитектурную проблему оптимизациями рендера.

Как отвечать на интервью

Сильный ответ на вопрос про client state vs server state обычно звучит так: сначала вы разделяете состояния по источнику истины и жизненному циклу, потом объясняете границу ответственности и только после этого называете инструменты. Хорошая формулировка может быть такой:

"Client state принадлежит интерфейсу и меняется действиями пользователя внутри приложения. Server state принадлежит серверу, может устаревать независимо от UI и требует отдельного жизненного цикла: cache, refetch, invalidation и согласования после мутаций. Ошибка возникает, когда ответ сервера начинают редактировать как обычный локальный state. Тогда появляются дубли источников истины, проблемы с формами и лишние синхронизации".

Если хочется усилить ответ до middle+, добавьте production-контекст: где именно это ломается, как вы это заметите и какие компромиссы выберете. Например, можно сказать, что локальный draft формы уместен, но только как изолированная рабочая копия, а не как новая постоянная истина для серверных данных.

Потренируйте React-интервью на вопросах, где важна инженерная логика

Практика по state management, data fetching, performance и архитектурным trade-off без шаблонных ответов и заученных формулировок

Начать подготовку

FAQ

Можно ли вообще обойтись без отдельной библиотеки для server state?

Да, если приложение небольшое и требования простые. Но как только появляются повторное использование данных, мутации, фоновое обновление и несколько точек чтения, самодельный слой быстро повторяет часть query-библиотеки, только хуже и дороже в поддержке.

Redux, Zustand и Context решают задачу server state?

Они могут хранить данные, но сами по себе не решают жизненный цикл server state. Для этого обычно нужен отдельный слой, который понимает устаревание, ключи запросов, повторные запросы и invalidation.

Почему нельзя просто после каждого изменения заново вызывать fetch?

Можно, но это не всегда достаточно. Повторный запрос не решает дедупликацию, локальные optimistic updates, согласованность нескольких экранов и защиту пользовательского черновика от фонового обновления.

Где хранить фильтры списка: в query cache, store или URL?

Если фильтры должны переживать reload, передаваться через ссылку и участвовать в навигации, обычно лучший кандидат URL. Если это локальный временный переключатель без навигационного смысла, подойдет client state рядом с компонентом.

Что важнее на собеседовании: назвать TanStack Query или объяснить границу состояния?

Граница состояния. Название библиотеки без объяснения ownership и lifecycle звучит поверхностно. Интервьюер обычно ждет reasoning, а не список инструментов.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Автор

Lexicon Team

Читайте также