
Привет. Я Дима Рагозин, фронтенд-разработчик в KTS. Эту статью я хочу начать с предыстории.
Полтора года назад на проекте для одного крупного клиента мы получили задачу — ускорить главную страницу. К тому моменту в кодовой базе уже жили два отдельных фронтенд-приложения под две разные платформы — CSR-версия (Client Side Rendering) и SSR‑версия (Server Side Rendering), — а MobX‑сторы все время жизни проекта разрастались вместе с функциональностью.
Каждый новый экран приносил еще один класс (а то и несколько), еще кучу связей, и в какой‑то момент мы стали замечать снижение воспринимаемой скорости приложения, избыточные HTTP‑запросы, сложности с поддерживаемостью и другие проблемы, которые становились критичнее по мере роста проекта. В статье я расскажу о том, как мы шаг за шагом перевели такие сторы на React Query, сократили код вокруг запросов на ≈50 % и практически избавились от повторных GET‑ов. Попутно поведаю о наших граблях и поделюсь советами по миграции.
Что разберем:
мотивацию бизнеса и технические проблемы, из‑за которых мы задумались о миграции;
стратегию миграции: как жить, пока MobX и React Query работают бок о бок;
ключевые элементы новой архитектуры;
где набили шишки;
результаты — что удалось выиграть и где все еще держим MobX.
Статья пригодится фронтенд‑разработчикам и тимлидам, которые думают о переезде на React Query и хотят оценить подводные камни.
Оглавление
Исходная архитектура на MobX
Прежде чем ввести React Query, все состояние (и серверное, и клиентское) мы держали в MobX‑сторах. Ниже — очень упрощенный пример того, как это выглядело. В боевом коде мы активно используем разделение на приватные/публичные поля, состояния загрузки, утилитарные классы для пагинаций и прочее, но для статьи оставим короткую выжимку.
В статье я сознательно делаю упор на SSR‑окружение на примере Next.js — именно там прячется больше всего подводных камней при миграции и работе с данными. Тем не менее, по ходу повествования я буду вставлять ремарки о том, что происходит в чистом CSR. В общем случае в нем все сильно проще.
Глобальные сторы
Это объекты, которые хранят некое глобальное состояние, доступное в любой точке приложения. Рассмотрим пример стора для хранения состояния пользователя:
import { makeAutoObservable, runInAction } from 'mobx';
export class UserStore {
user: UserEntity | null = null;
constructor() {
makeAutoObservable(this);
}
init = async () => {
const response = await fetchUser();
if (response.error) return;
runInAction(() => {
this.user = response.data;
});
};
}
В этом примере есть одно‑единственное наблюдаемое поле user и метод init(), который вызывается на стороне клиента. Благодаря makeAutoObservable экземпляр стора становится реактивным (любой компонент, который читает user, автоматически перерисовывается при изменении данных).
Сценарий намеренно упрощен: стор ничего не делает на стороне сервера, куки или параметры запроса не читаются. В боевом коде бывает сложнее: например, когда нужно инициализировать стор значением из куки на стороне сервера. Но для демонстрации хватит такого минимального варианта.
Паттерн примерно повторялся для всех глобальных сторов.
Провайдер стора создавал его singleton‑экземпляр и прокидывал через контекст:
const IS_SSR = typeof window === 'undefined';
enableStaticRendering(IS_SSR);
const StoreContext = createContext<UserStore | null>(null);
export const UserStoreProvider = ({ children }: { children: ReactNode }) => {
const ref = useRef<UserStore | null>(null);
if (!ref.current || IS_SSR) {
ref.current = new UserStore();
}
return (
<StoreContext.Provider value={ref.current}>
{children}
</StoreContext.Provider>
);
};
На клиенте экземпляр UserStore создается один раз и живет столько же, сколько живет клиентское приложение, что обеспечивает единый источник истины для всех компонентов.
На сервере — наоборот: при каждом запросе за страницей создается новый UserStore, чтобы данные разных пользователей не пересекались между запросами.
Локальные сторы
Также в проекте мы широко использовали локальные сторы. Такой стор создается и живет вместе с конкретным компонентом (чаще всего со страницей): при размонтировании компонента данные удаляются, реакции и запросы отменяются.
При этом приходилось поддерживать два независимых сценария работы:
Флоу клиентского рендеринга — использовался CSR-приложением. Внутри компонента страницы в useEffect вызываем loadData(), стор делает запрос и кладет результат в observable‑поле. Все довольно линейно.
Флоу серверного рендеринга (SSR) — Next.js рендерит страницу на сервере, поэтому класс предоставляет статический метод loadInitialData(), с помощью которого можно получить данные для инициализации стора. Мы вызываем этот метод в серверном компоненте, получаем данные, прокидываем в пропс клиентского компонента страницы и передаем их в конструктор локального стора. Клиент стартует с готовым состоянием и не делает запрос за данными.
import { makeAutoObservable, runInAction } from 'mobx';
type Params = { initialData?: BlogPostEntity };
export class BlogPostPageStore {
blogPost: BlogPostEntity | null = null;
constructor({ initialData }: Params = {}) {
if (initialData) this.blogPost = initialData;
makeAutoObservable(this);
}
// Клиентский флоу (CSR)
loadData = async () => {
const response = await fetchBlogPost();
if (response.data) {
runInAction(() => {
this.blogPost = response.data;
});
}
};
// Серверный флоу (SSR)
static loadInitialData = () => fetchBlogPost();
}
Серверный компонент страницы (SSR-приложение)
Запрашиваем начальные данные и прокидываем в клиентский компонент страницы:
export default async function Page() {
const initial = await BlogPostPageStore.loadInitialData();
if (!initial.data) notFound();
return <BlogPostPage initialData={initial.data} />;
}
Клиентский компонент страницы (SSR-приложение)
Получаем начальные данные и ими инициализируем локальный стор:
'use client';
import { observer } from 'mobx-react-lite';
const BlogPostPage = ({ initialData }: { initialData: BlogPostEntity }) => {
const [store] = useState(() => new BlogPostPageStore({ initialData }));
if (!store.blogPost) return null;
return (
// ...
);
};
export default observer(BlogPostPage);
CSR-приложение
В случае CSR-приложения просто происходил вызов инициализирующего стор метода в useEffect:
import { observer } from 'mobx-react-lite';
export const BlogPostPage = ({ initialData }: { initialData: BlogPostEntity }) => {
const [store] = useState(() => new BlogPostPageStore({ initialData }));
useEffect(() => {
store.loadData();
}, []);
if (!store.blogPost) return <div>Загрузка...</div>;
return (
// ...
);
};
export default observer(BlogPostPage);
Итог об архитектуре MobX-сторов
Глобальные сторы хранят глобальное состояние и могут содержать подсторы.
Локальные сторы хранят состояние в рамках компонента, могут быть унаследованы от родительского стора, могут содержать подсторы. Такие сторы выполняют основную работу по загрузке информации из API.
Рутинная работа выполняется утилитарными подсторами.
Что болит в MobX и от чего хотим избавиться
Повторные запросы: локальные сторы обнуляются при размонтировании, поэтому при возврате на страницу данные перезапрашиваются.
Разрастание классов: чем больше функциональности, тем сложнее ориентироваться в сторах. При этом части функционала объединялись в подсторы. Учитывая наличие двух приложений (CSR и SSR), если различия между ними были значительными, мы выносили общие вещи в «базовый» родительский класс и наследовали от него отдельные сторы под каждое приложение. Со временем получилась длинная цепочка наследования. Отследить, как именно данные переходят между уровнями наследования, становилось все сложнее. Однажды мне даже пришлось во время код-ревью накидать UML-схему сторов, чтобы разобраться…
Два жизненных цикла для SSR и CSR: сама по себе поддержка двух флоу (CSR и SSR) может быть громоздкой, много риска допустить ошибку, хотя в целом алгоритм действий довольно прямолинейный.
Почему выбрали React Query
Началось все с главной страницы. Из‑за локального стора данные стирались при каждом уходе со страницы, и при повторном визите приложение заново выполняло запрос в API. Пользователь видел экран загрузки, а воспринимаемая скорость приложения была очень малой. Очевидно, главная страница — это самая посещаемая страница, и оптимизация была необходима.
Кеш React Query решил проблему — запросы выполняются один раз, а при следующем переходе страница рисуется сразу по данным из кеша.
Побочный бонус — восстановление позиции скролла. Раньше стор сбрасывался, DOM пересоздавался, и позиция прокрутки улетала в начало страницы. С React Query данные остаются в кеше, компонент рендерится моментально и скролл не «прыгает».
Оптимизация оказалась успешной: большая часть «обвязки», которую мы писали в MobX (состояния загрузки, индикаторы ошибок, подгрузка данных с пагинацией, хранение данных), стала просто не нужна и впоследствии была удалена. React Query взял на себя кеш и прочие рутинные задачи, а мы смогли сконцентрироваться на бизнес‑логике.
Стратегия миграции
После того как мы убедились, что React Query заметно уменьшает объем кода и упрощает обработку запросов, мы решили двигаться в сторону полного отказа от MobX. Командное правило теперь такое:
Любое изменение API‑эндпоинта или создание нового раздела сразу пишем на React Query.
Если появляется свободное время, то переписываем на React Query старые экраны.
Пока миграция не завершена, MobX и React Query просто сосуществуют: где нужно связать какие-то данные, просто прокидываем пропсы, колбэки или реагируем через useEffect — без тяжелых адаптеров.
Новый подход к запросам
Для тех, кто еще не работал с библиотекой, делаю короткий обзор основных концепций:
Query — результат выполнения хуков useQuery и useInfiniteQuery. Это удобное представление GET‑запроса: данные и ассоциированный с ними кеш, статусы выполнения запроса. Каждая query и ее кеш идентифицируется с помощью уникального ключа query key.
Mutation — результат выполнения хука useMutation, предназначенного для вызова действий, которые изменяют данные (POST/PUT/DELETE). Mutation тоже предоставляет статус выполнения, а также удобный API для выполнения сайд-эффектов.
QueryClient — объект, который хранит кеш и глобальные настройки React Query, а также предоставляет API для взаимодействия с кешем queries (например, инвалидация).
Далее в статье эти абстракции я так и буду называть: queries, mutations и QueryClient.
Наш новый подход к запросам складывается из пяти ключевых элементов — ровно столько нам нужно, чтобы покрыть большинство сценариев проекта:
Функция request. Поскольку раньше запросы происходили через утилитарный MobX-стор, и экземпляры класса запроса вместе со всей логикой были сконцентрированы там, потребовалось создать отдельную функцию для запросов. Самую обычную и знакомую любому: сериализуем тело или параметры запроса, добавляем заголовки, проверяем ответ и другое. Ключевое здесь (и об этом мы еще поговорим): если произошла ошибка, выбрасывать ошибку, а не представлять ее в виде абстракции и возвращать из функции. Впоследствии для каждого запроса создаются свои обертки с парсингом ответа, например так:
const requestEventDetail = async ({ id, cityAlias }: Params) => {
const response = await request({
url: ENDPOINTS.eventDetail.getUrl(id),
method: ENDPOINTS.eventDetail.method,
params: {
city_alias: cityAlias
}
});
// Парсинг по zod-схеме
return eventDetailSchema.parse(response);
}
Ассоциативный массив с ключами для идентификации queries. Такие ключи нужно обязательно использовать при работе с queries, чтобы впоследствии можно было работать с кешом. Но это необязательно исчерпывающий набор ключей для конкретной query: итоговый ключ будет включать в себя в том числе необходимые параметры запроса.
// Ключи
const REACT_QUERY_KEYS = {
eventDetail: (id: string) => ENDPOINTS.eventDetail.getUrl(id),
favorites: () => ENDPOINTS.favorites.getUrl(),
// ...
};
// Запрос
const query = useQuery({
queryKey: [REACT_QUERY_KEYS.eventDetail(id), { cityAlias }],
// ...
});
// Работа с кешом
queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEYS.eventDetail(id)],
});
Обертки над useQuery. Хук useQuery достаточно декларативный, но чтобы скрыть лишние детали, мы делаем свой хук для конкретной query. Там инкапсулирована логика по подготовке ключей, работа с функцией request и так далее.
const useEventDetailQuery = ({ id, cityAlias }: Params) =>
useQuery({
queryKey: [BASE_REACT_QUERY_KEYS.eventDetail(id), { cityAlias }],
queryFn: () => requestEventDetail({ id, cityAlias }),
});
Хук-обертка над useInfiniteQuery для запроса пагинируемых списков с бесконечным скроллом. Такой хук адаптирует API React Query под наши нужды: получение курсора на следующую страницу пагинации, разворачивание страниц пагинации в сплошной список и другие. Мы на нашем проекте используем пагинацию через обозначение размера страницы пагинации и номера запрашиваемой страницы. Вот упрощенный пример:
Хук-обертка над useInfiniteQuery
type PaginationEntity<I> = {
count: number;
next: number | null;
results: I[];
};
type RequestPaginatedDataParams<T, TApi> = {
pageLimit?: number;
startPage?: number;
schemaParser: (raw: PaginationEntity<TApi>) => PaginationEntity<T>;
} & RequestParams;
const requestPaginatedData = async <T, TApi>({
params,
pageLimit,
startPage = 0,
schemaParser,
...init
}: RequestPaginatedDataParams<T, TApi>): Promise<PaginationEntity<T>> => {
const response = await request<PaginationEntity<TApi>>({
params: {
page: startPage,
page_size: pageLimit,
...params,
},
...init,
});
return schemaParser(response);
};
type PaginatedQueryParams<T> = Partial<
UndefinedInitialDataInfiniteOptions<
PaginationEntity<T>,
DefaultError,
InfiniteData<PaginationEntity<T>, number>,
QueryKey,
number
>
>;
const usePaginatedQuery = <T>({
initialPageParam = 0,
getNextPageParam = (page) => page.next,
...queryInit
}: PaginatedQueryParams<T>) => {
const query = useInfiniteQuery({
initialPageParam,
getNextPageParam,
...queryInit,
});
const flatData = useMemo(
() => query.data?.pages.map((page) => page.results).flat() ?? [],
[query.data]
);
const total = query.data?.pages.at(-1)?.count ?? 0;
const isEmpty = !flatData.length && query.isSuccess;
return {
...query,
flatData,
isEmpty,
total,
};
};
// Пример использования:
const eventsPaginationEntitySchema = z.object({
count: z.number(),
next: z.number().nullable(),
results: z.array(eventEntitySchema),
});
type RequestEventsParams = {
pageParam: number;
params: Record<string, unknown>;
};
const requestEvents = ({ pageParam, params }: RequestEventsParams) =>
requestPaginatedData<EventEntity, EventEntityApi>({
params,
startPage: pageParam,
url: ENDPOINTS.events.getUrl(),
method: ENDPOINTS.events.method,
schemaParser: eventsPaginationEntitySchema.parse,
});
type UseEventsQueryParams = {
params: Record<string, unknown>;
};
const useEventsQuery = ({ params }: UseEventsQueryParams) =>
usePaginatedQuery({
queryKey: [REACT_QUERY_KEYS.events(), params],
queryFn: ({ pageParam }) => requestEvents({ pageParam, params }),
});
-
SSR-флоу. Ключевое здесь — запросить нужные данные на стороне сервера и таким образом заранее наполнить кеш React Query по соответствующему query key. Это делается в несколько шагов:
Все приложение оборачивается в QueryClientProvider.
Создается новый QueryClient при каждом запросе страницы (мы не хотим, чтобы данные между запросами смешивались).
С использованием созданного QueryClient запрашиваются данные.
QueryClient дегидрируется и передается в клиентский компонент страницы, где впоследствии произойдет гидратация.
SSR-флоу
// page.tsx (Серверный компонент страницы)
type Params = {
id: string;
cityAlias: string;
queryClient: QueryClient;
};
// Делает запрос, сохраняет данные в кеше и возвращает результат запроса
const fetchEventDetailQuery = async ({ queryClient, id, cityAlias }: Params) =>
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEYS.eventDetail(id), { cityAlias }],
queryFn: async () =>
requestEventDetail({
id,
cityAlias,
}),
});
type PageProps = {
params: Promise<{ city: string; id: string }>;
};
export default async function Page(props: PageProps) {
const { city, id } = await props.params;
const queryClient = new QueryClient();
try {
await fetchEventDetailQuery({ id, cityAlias: city });
return (
<EventDetail
id={id}
cityAlias={city}
dehydratedState={dehydrate(queryClient)}
/>
);
} catch (error) {
return <SomethingWentWrongErrorPage />;
}
}
// EventDetail.tsx (Клиентский компонент страницы)
type Props = {
id: string;
cityAlias: string;
};
const EventDetail = ({ id, cityAlias }: Props) => {
// Данные попадают в useQuery из кеша дегидрированного QueryClient
const event = useEventDetailQuery({
id,
cityAlias,
});
return <div>{/* Вёрстка страницы */}</div>;
};
// В боевом проекте лучше вынести логику в HOC
export const EventDetailWithDehydratedState = ({
dehydratedState,
...props
}: Props & { dehydratedState: unknown }) => (
<HydrationBoundary state={dehydratedState}>
<EventDetail {...props} />
</HydrationBoundary>
);
Итог о новой архитектуре
Есть набор утилитарных функций и хуков, адаптирующих работу React Query под нужды приложения.
Для каждого кейса есть три ключевых аспекта: функция для запроса данных, функция для запроса и обогащения QueryClient на стороне сервера и хук для запроса данных поверх useQuery/useInfiniteQuery для использования на стороне клиента.
Обращаться к кешу QueryClient можно в любом месте приложения без необходимости вручную распространять данные в React-контексте.
Грабли и инсайты
Давайте пройдемся по ошибкам, которые мы совершили в свое время, и разберемся, как их не допускать. Я постарался изложить кейсы, которые были наименее очевидными для нас.
Коллизии queryKey между useQuery и useInfiniteQuery
Кейс: у вас есть метод API, который умеет отдавать пагинируемый список. На одной странице вы хотите отображать фиксированное количество элементов списка, а на другой расположить бесконечный список. Вы можете написать так:
// Пример запроса с пагинацией
const paginatedQuery = useInfiniteQuery({
queryKey: ['my-key', { category: 'music' }],
queryFn: ({ pageParam }) =>
requestEvents({ pageParam, pageLimit: 20, params: { category: 'music' } }),
// ...
});
// Пример запроса с фиксированным количеством элементов
const fixedSizeQuery = useQuery({
queryKey: ['my-key', { category: 'music' }],
queryFn: () =>
requestEvents({ pageLimit: 5, params: { category: 'music' } }),
// ...
});
В коде выше возникает проблема. Несмотря на то, что для запросов используются разные хуки, они записывают в одну и ту же ячейку кеша React Query. В результате в одном из кейсов вам может прийти некорректное количество данных. Поэтому нужно явно обозначать различие этих кейсов в query key:
// Плохо: useQuery и useInfiniteQuery пишут в одну и ту же ячейку кеша
queryKey: ['my-key', { category: 'music' }]
// Хорошо: ключи различаются для useQuery и useInfiniteQuery
queryKey: ['my-key-fixed-size', { category: 'music' }]
queryKey: ['my-key-paginated', { category: 'music' }]
Порядок элементов массива
Кейс: ваше приложение предоставляет возможность выбрать несколько опций для фильтрации некоторых сущностей. Пользователь может выбирать опции в различном порядке, а приложение представляет эти опции в виде массива и использует в качестве параметров запроса сущностей. Очень органично в данном случае будет использовать полученные параметры запроса в качестве query key:
queryKey: ['my-key', { options: ['A', 'B', 'C'] }]
queryKey: ['my-key', { options: ['B', 'A', 'C'] }]
queryKey: ['my-key', { options: ['C', 'B', 'A'] }]
Однако здесь кроется большая проблема: разный порядок в массивах порождает разные записи в кеше React Query. В то же время это не касается порядка следования полей в объектах. Мы в команде выбрали подход, заключающийся в сортировке всех массивов, которые встречаются в параметрах, чтобы query key всегда формировался однозначным образом:
// Плохо: разный порядок элементов приводит к разным query key
queryKey: ['my-key', { options: ['A', 'B', 'C'] }]
queryKey: ['my-key', { options: ['B', 'A', 'C'] }]
// Хорошо: элементы сортируются, query key одинаковые
queryKey: ['my-key', sortArrays({ options: ['A', 'B', 'C'] })]
queryKey: ['my-key', sortArrays({ options: ['B', 'A', 'C'] })]
Разные query key на сервере и на клиенте (SSR-флоу)
По различным причинам у вас может случиться так, что query key, используемые на стороне сервера, могут отличаться от таковых на стороне клиента. Это особенно рискованно, если у вас нет единого генератора query key для запросов на сервере и на клиенте, или если вы, к примеру, работаете с точными датами и временем, которые могут оказаться различными на сервере и на клиенте ввиду разных часовых поясов.
Различие query key может привести к лишнему запросу данных на стороне клиента и, соответственно, к скачкам интерфейса сразу после гидратации. Поэтому важно уделить внимание полному соответствию ключей на сервере и на клиенте.
Если произошла ошибка, нужно ее выбросить
Раньше мы обрабатывали ошибки путем возвращения из функции некого объекта-представления ошибки, а не выбрасывали ошибку через throw. Из-за этого React Query не мог определить статус выполнения, и, фактически, все запросы были успешными. Чтобы не делать очередной велосипед, мы пошли по пути выбрасывания ошибки в случае, если что-то пошло не так.
// Плохо: ошибка не выбрасывается, React Query считает запрос успешным
useQuery({
queryFn: () => {
const response = <...>;
if (response.error) {
return null;
}
return response.data;
},
});
// Хорошо: ошибка выбрасывается, React Query корректно определяет статус запроса
useQuery({
queryFn: () => {
const response = <...>;
if (response.error) {
throw new Error(response.error);
}
return response.data;
},
});
Перезапрос пагинируемого списка работает медленно
Допустим, вам нужно перезапросить пагинируемый список (useInfiniteQuery). Это может понадобиться, например, если произошла инвалидация кеша или вы явно вызвали метод refetch(). В таком случае React Query будет делать это очень медленно, дожидаясь загрузки предыдущей страницы пагинации, прежде чем начать загружать следующую — и так, пока не перезапросит все страницы.
Если ваш бэкенд медленный, такая долгая обработка может стать критичной. Будет быстрее выполнить пару запросов по 100 элементов, чем два десятка запросов по 10 элементов. Более того, мы хотим делать запросы параллельно.
К сожалению, если у вас нет иного выхода, в таком случае нужно писать костыль и идти в обход состояний React Query. Разберем пример с постраничной пагинацией (нумерация страниц начинается с нуля):
Перезапрос пагинируемого списка целиком
import { useInfiniteQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query';
const PAGE_LIMIT = 10;
const MAX_LIMIT = 100;
const QUERY_KEY = ['my-key'];
// Загрузить сущности за минимальное количество запросов
const requestManyEvents = async (eventsCount: number) => {
let eventsCountRest = eventsCount;
let startPage = 0;
const requests = [];
while (eventsCountRest > 0) {
const pageLimit = Math.min(MAX_LIMIT, eventsCountRest);
requests.push(
requestEvents({ startPage, pageLimit })
);
startPage++;
eventsCountRest -= pageLimit;
}
const responsePages = await Promise.all(requests);
return {
events: responsePages.map((page) => page.results).flat(),
total: responsePages.at(-1).total,
};
};
// Разделить сущности на страницы в формате useInfiniteQuery
const splitIntoInfiniteQueryPages = (
events: EventEntity[],
total: number
): InfiniteData<EventEntity> => {
const totalPagesCount = Math.ceil(total / PAGE_LIMIT);
const pagesCount = Math.ceil(events.length / PAGE_LIMIT);
return {
pages: events.reduce<PaginationEntity<EventEntity>[]>((acc, event, i) => {
const page = Math.floor(i / PAGE_LIMIT);
if (!acc[page]) {
acc[page] = { results: [], count: total, next: null };
if (page < totalPagesCount - 1) {
acc[page].next = page + 1;
}
}
acc[page].results.push(event);
return acc;
}, []),
pageParams: Array.from({ length: pagesCount }).map((_, i) => i),
};
};
const useEventsQuery = () => {
const queryClient = useQueryClient();
const query = useInfiniteQuery({
queryKey: QUERY_KEY,
queryFn: ({ pageParam }) => requestEvents({ startPage: pageParam }),
// ...
},
queryClient
);
const flatData = useMemo(
() => query.data?.pages.map((page) => page.results).flat() ?? [],
[query.data]
);
const refetchAll = useCallback(async () => {
let count = Math.max(flatData.length, PAGE_LIMIT);
// Чтобы не сбивать разбиение на страницы пагинации,
// нам всегда нужно грузить число событий,
// кратное странице пагинации (можно не делать,
// если вы используете явные limit и offset)
if (count % PAGE_LIMIT !== 0) {
count = Math.ceil(count / PAGE_LIMIT) * PAGE_LIMIT;
}
const { events, total } = await requestManyEvents(count);
const pages = splitIntoInfiniteQueryPages(events, total);
queryClient.setQueryData(QUERY_KEY, pages);
}, [flatData.length, queryClient]);
return { ...query, flatData, refetchAll };
};
Конечно, прибегать к таким костылям крайне нежелательно. Так вы лишаетесь достоинств React Query в части облегчения рутинной работы с состояниями. Если это возможно, то лучшим решением будет увеличить размер страницы пагинации (чтобы последовательных запросов было меньше) или вовсе удалять кеш, а не инвалидировать, чтобы список загружался заново, с первой страницы пагинации.
Что получили
Было (MobX) |
Стало (React Query) |
Повторный GET при каждом возврате на страницу |
Мгновенное получение данных из кеша |
Громоздкие сторы с длинной цепочкой наследования |
Декларативные хуки (примерно вдвое меньше кода) |
Высокий порог входа в понимании реактивности MobX |
Декларативный, привычный для React подход с хуками |
Собственные инструменты для рутинных задач: состояния загрузки, пагинация и другие |
Проверенная сообществом библиотека |
Отсутствие строгих правил реализации и высокий риск оверинжиниринга |
Четкий флоу обработки запросов |
Когда MobX все еще уместен
Сложное клиентское состояние без большой зависимости от бэкенда. Когда основная задача — тонкая реактивность и вычисления прямо в браузере, MobX‑стор позволяет очень явно отделить бизнес‑логику от UI.
OOП‑стиль и «классовые» библиотеки. Если в проекте уже живут классы (например, из библиотек Phaser, Three.js), то MobX-сторы в виде классов встраивается органично, позволяя использовать все наследие подходов и паттернов из ООП. У нас в компании даже есть внутренний регламент о том, в каких кейсах стоит или не стоит использовать тот или иной паттерн проектирования вместе с MobX. В случае же React Query вы гвоздями прибиваете себя к архитектуре, построенной вокруг хуков и жизненного цикла React.
Гранулярная реактивность. В случае обновления конкретного observable-поля, MobX вызывает перерисовку только тех компонентов, которые зависят от этого поля и не перерисовывает те компоненты, которые обращаются к стору, но не используют это поле. Это отличает реактивность в MobX от таковой в React — когда изменение state может приводить к перерисовкам независимых от этого state компонентов. Поэтому если для производительности вашего приложения это важно (а это справедливо для действительно узкого круга кейсов), то отказываться от MobX полностью не стоит.
Универсальность. React Query — это все-таки больше про запросы и их обработку, а не про стейт-менеджмент. Если постараться, с помощью MobX можно реализовать все то, что предоставляет React Query. Вопрос только в том, какой ценой.
Заключение и выводы
В этой статье я сознательно не погружался глубоко в механизмы mutations и инвалидации кеша, хотя они и сделаны в React Query очень удобно. Я все же хотел сосредоточиться на аспектах, в которых кроется самый высокий риск ошибки.
Внедрив React Query, мы получили значительный прирост воспринимаемой скорости работы приложения, почти полностью избавились от повторных запросов в рамках одного сеанса работы с приложением и вдвое сократили количество кода для наиболее частых сценариев.
React Query из коробки отлично работает с запросами, кешем и SSR‑гидратацией. Инкрементальная миграция без глобального рефакторинга позволила нам получить профит без большого риска для проекта в целом. Глядя на наш проект, мы решили, что будем постепенно избавляться от MobX, заменяя работу с запросами на React Query, а немногочисленное глобальное состояние хранить в состоянии React. Но не потому, что MobX плох сам по себе, а потому, что React Query больше подходит под наши потребности.
Надеюсь, мне получится сократить ваше время, если вы все-таки решите рассмотреть внедрение React Query в свой проект. В комментариях предлагаю вам рассказать, что вы думаете об этой библиотеке, и был ли у вас подобный опыт изменения принципов работы с запросами.
А познакомиться с другими полезными библиотеками вы можете в статьях моих коллег-фронтендеров:
Three.js c нуля на практике: как за несколько часов создать аркадную 3D-игру. Часть 1
Дополненная реальность в Web: какие библиотеки актуальны в 2025?
Как сделать анимацию разными способами: CSS, WebP, Canvas, Lottie, Spine и секвенции
Next.js + Playwright. Как мы начали писать автотесты и что из этого вышло
Комментарии (21)
armenat
07.08.2025 21:40Работаю с Vue и Pinia, поэтому могу не знать особенностей работы MobX, но нельзя разве сделать так, чтоб при уходе со страницы стор не очищался? Беглый гугл выдает пакет mobx-persist-store.
Хотелось бы побольше узнать, почему было легче переписать все на другую технологию
dewolix Автор
07.08.2025 21:40Сделать так, чтоб при уходе со страницы стор не очищался
Да, конечно, можно. Как уже говорил, всё, что есть в React Query, можно сделать вместе с MobX. По идее в том числе предложенным вами пакетом, просто вместо localStorage подсунуть какой-то глобальный storage (хранение в localStorage нам было бы избыточно). В целом можно бы было ещё проще — сделать локальный стор подобием глобального и сделать свои механизмы работы с таким подобием кеша
Но снова: пришлось бы явно разделять, как стор ведёт себя на клиенте и на сервере (на клиенте сохраняем состояние, на сервере всегда создаём новое); пришлось бы задумываться, как обрабатывать состояния загрузки при наличии persistent-состония; как реализовать stale-while-revalidate, а не просто сбрасывать состояние и загружать новое; как работать с пагинируемыми списками и так далее — всё это уже реализовано в React Query
Здесь же немаловажен вопрос поддерживаемости. Поскольку самая частая цель сторов на проекте была в том, чтобы "загрузить данные из API и хранить", переписывание на другую технологию заключалось просто в том, чтобы удалить стор, написать функцию запроса и обернуть её в useQuery/useInfiniteQuery, подобрав query-ключи — это буквально всё. Остальную рутину решает React Query под капотом, в том числе гидрацию при с SSR. На те грабли, которые я привёл в статье, мы бы в любом случае наступили, реализовывали бы мы своё решение или использовали бы что-то новое. Поэтому да, скорее, легче было просто переписать
dewolix Автор
07.08.2025 21:40А как с Vue+Pinia? Там это проще решается? Я сам никогда Vue плотно не изучал, интересно, как там
DmitryOlkhovoi
07.08.2025 21:40Vue во всем гораздо проще, так и библиотечки под него. Но конкретно в этом случае, будет так же плагин под Pinia. Что бы был persist
С реакт квери доходит до смешного, что они дропнули поддержку старых safari например. Дропать старое ок, но часто это какие-то проценты пользователей. А что может быть такого, в этой библиотеке, что она перестает поддерживать старые браузеры.
dominus_augustus
07.08.2025 21:40Вместо ухода от mobx можно было просто написать адаптер под react query и использовать его в рамках существующих классов. Тем более его уже написали и достаточно скачать библиотеку react query mobx. Касаемо mobx, он в первую очередь нужен для реактивности, а ваша проблема заключалась не в том, что mobx какой-то не такой, а в том, что использовался неправильный инструмент, вам нужен был клиентский кэш, вот и все.
Длинные цепочки наследования - зло, нужно предпочитать композицию
dewolix Автор
07.08.2025 21:40Да, безусловно, проблема не в том, что MobX какой-то не такой, и я указал в статье, что просто дело в том, что React Query больше подошёл под наши потребности, а для своих нужд MobX — отличный инструмент
Мне кажется, даже если бы мы написали адаптер — это был бы больше костыль и лишнее усложнение в поддерживаемости кода. Мы бы в и без того сложную структуру встроили кеширование через React Query, а вместе с тем потянулись бы и сложности в SSR и другие. Вместо этого легче было написать запрос и завернуть его в useQuery/useInfiniteQuery, а все состояния загрузок, гидрацию, ревалидацию React Query решет под капотом. Для связи MobX и React Query нам достаточно было реагирования useEffect'ами без дополнительных обёрток. Вероятно, для проектов, где реактивность нужна более тонкая, адаптер бы подошёл лучше
По поводу пакета react-query-mobx, прошу прощения, мб не так искал — не нашёл. Нашёл только mobx-ecosystem/mobx-react-query с пятью скачиваниями за последнюю неделю. Кажется, это не то, что вы имели в виду
Про наследование да, нужно не заиграться в ООП во фронтенде хехе....
dominus_augustus
07.08.2025 21:40В npm "mobx-tanstack-query". Поэтому я и указал про этот пакет, чтобы самим не изобретать велосипед. По сути тот же ReactQuery/TanstackQuery только не прибитый к реакту
kubk
07.08.2025 21:40Если у вас половина кода это ручной фетчинг данных через MobX, то можно было вынести это в библиотеку с кэшем и перестать перезапрашивать одно и то же. К тому же есть и библиотеки вроде mobx-persist-store, где можно в пару строчек сделать синхронизацию observable с sessionStorage / localStorage / IndexedDB. И наследование не обязательно:
class DashboardStore { products = new RequestStore(api.users.get) products = new RequestStore(api.products.get) orders = new RequestStore(api.orders.get) }
Один такой RequestStore тоже заменил бы вам половину проекта. Даже классы не обязательны:
const counter = makeAutoObservable({ value: 0, increment() { counter.value++ } })
Просто классы удобнее с TypeScript, так как не нужно описывать тип стора вручную. От этой проблемы страдает например Zustand: https://zustand.docs.pmnd.rs/guides/typescript
dewolix Автор
07.08.2025 21:40Спасибо за идею, действительно интересно бы было сделать такую либу. Но, боюсь, нам бы это дало не меньше головной боли с ревалидицией кеша (в смысле механизм stale-while-revalidate), его сбросом, его редактированием, отключением синхронизации на сервере и включением на клиенте. Всё так же надо было реализовывать вручную гидрацию стора на клиенте по данным с сервера. В общем, всё это действительно добавляет лишнего оверхеда, когда в React Query это уже всё реализовано и поддержано сообществом
kubk
07.08.2025 21:40Как человек, написавший несколько проектов на react-query за последние полгода, опишу реальные проблемы это библиотеки. Первое это невозможность иметь несколько представлений для одних и тех же данных. Например мы отрисовали 2 списка в разных компонентах через хук и теперь нужно отсортировать список. Чтобы сортировка одного списка стриггерила сортировку другого нужно либо обновлять данные глобально:
queryClient.setQueryData(['projects'], newOrder);
и тогда мы имеем лишь одно представление для данных, что на больших проектах будет ограничивать
либо используем функцию сортировки в двух компонентах, но как-то костылями триггерим перерисовку второго. В Mobx же всё просто - сущности это observable, а разные представления сущностей - это computed. И computed'ов можем быть сколько угодно:class ProjectStore { projects = [] sortOrder = 'default' get sortedProjects() { return this.sortOrder === 'alphabetical' ? this.projects.slice().sort((a, b) => a.name.localeCompare(b.name)) : this.projects }
Второе что react-query это всё-таки просто кеш. Он не даёт вообще никаких удобств для написания интерактивных приложений. Чтобы жить только на react-query приходится использовать React Context с хуками, а это перерисовка лишних компонентов на каждый чих + невозможность использовать эту бизнес-логику вне реакт компонентов. По моему опыту чем больше в проекте интерактива, тем сильнее код превращается в лапшу из хуков дёргающих друг друга. Поэтому для меня react-query - классно для неинтерактивных приложений где больше чтения чем записи, либо запись примитивная. А для сложной клиентской логики нужен стейт-менеджер.dewolix Автор
07.08.2025 21:40Спасибо, что поделились опытом и подводными камнями!
Невозможность иметь несколько представлений для одних и тех же данных. Например мы отрисовали 2 списка в разных компонентах через хук и теперь нужно отсортировать список
У нас проект, честно сказать, не прям очень интерактивный и всё, что сложнее сортировки массива, мы просто перемещаем на сторону API. В тому же, скорее всего, такой список у нас был бы пагинируемым, а на клиенте пагинируемый список не отсортируешь, поэтому точно эти манипуляции мы бы оставили бэкенду
Ну и, вероятно, у вас чуть другой кейс. Для нас кейс с разными представлениями данных выглядел, скорее, так: юзер зашёл на главную — увидел там какой-то набор сущностей с минимальным количеством данных; зашёл в деталку какой-то сущности — там расширенный набор данных (данные из списка переиспользовать не получится); зашёл в поиск — там тоже некоторый список сущностей с разным количеством данных и не факт, что этот список пересекается с другими. В общем, для нас вопрос представления одних и тех же данных разным образом не стоял остро, но я согласен с вами, с React Query это, действительно, может быть неудобно
react-query это всё-таки просто кеш. Он не даёт вообще никаких удобств для написания интерактивных приложений
Полностью согласен с вами (и об этом упомянул в статье). В нашем случае не нужно было много интерактивности, поэтому React Query отлично подошёл. Максимум интерактивности в нашем кейсе — это добавление каких-то сущностей в избранное, например. Точно не дашборды или игровые механики
stepkir
07.08.2025 21:40Вместо итога "мы получили значительный прирост воспринимаемой скорости работы приложения" было бы интереснее взглянуть на метрики перфоманса после перехода на react query. Воспринимаемая скорость, все же, штука субъективная, а вот метрики точно скажут, где стало лучше/хуже и насколько.
dewolix Автор
07.08.2025 21:40На самом деле, я пока писал статью, жёстко загорелся идеей написать сколько-то объёмные два приложения с одинаковым функционалом очень похожим на наш кейс, но чтобы общение с API было бы реализовано по-разному: через MobX и утилитарные классы и React Query. Это я хотел размеры бандлов сравнить, но чтобы не просто размер библиотек сравнивать, а чтобы tree shaking какой-нибудь сработал и другие оптимизации. И сделал это с помощью Codex от OpenAI за пару вечеров. Выглядело ужасно, но для эксперимента было достаточно. В итоге я не увидел значительного различия совсем. Почти одинаковые цифры по размерам бандлов. А что касается нашего кейса, поскольку две библиотеки пока живут вместе в одном проекте, не получится сказать о каком-то улучшении в этом плане
Если говорить о перерендерах, то, как уже отметил в статье, MobX позволяет точечно реагировать компонентам на изменение конкретных полей, которые они слушают. В React Query всё-таки когда изменяем кеш вручную, это делается иммутабельно, поэтому там могут быть лишние ререндеры. Но здесь уже вопрос о том, приемлемо ли это для конкретного приложения. Для нас это было ок
Касательно перформанса в смысле "абсолютной" скорости, а не "воспринимаемой", в плане общения с API фронтовая библиотека вряд ли что-то может предложить для улучшения ситуации с сетью, поэтому мне показалось более резонным сделать упор на воспринимаемую скорость и избавление от лишних запросов. Так, для среднего сеанса пользователя (типа "зашли на главную, подгрузили список, оттуда — в деталку, потом обратно"), я бы сказал, что мы избавились от лишних перезапросов почти полностью, поскольку данные не обновляются очень часто. В случае нашего предыдущего сетапа без кеширования у нас бы на каждый шаг происходил новый запрос
Спасибо за комментарий. Надеюсь, я донёс, почему рассматривал кейс по большей мере с точки зрения воспринимаемой скорости
DmitryKazakov8
07.08.2025 21:40Спасибо за статью, но сравнивать Mobx, который просто добавляет реактивность объектам (можно его воспринимать как полифилл для deprecated метода
Object.observe
), и библиотеку для запросов не слишком корректно. На мой взгляд, в статье нужно было сравнивать "подход на классах, в котором самостоятельно строится работа с API" и "готовая библиотека, завязанная на Реакт и работающая на хуках". Зачем здесь упомянут Mobx, который просто вынесенные в классы данные делает реактивными, из статьи неясно. Можно просто убратьmakeAutoObservable
и сделать ручнойforceUpdate
компонентов либо увеличиватьcounter
, и весь код и проблемы останутся такими же, только без упоминания Mobx в статье.Все описанные недостатки относятся к архитектуре и реализации в вашем проекте (повторные запросы, громоздкие сторы с длинной цепочкой наследования, ручная обработка состояний загрузки, отсутствие строгих правил реализации) и говорят только о том, что команде такой подход не подошел - не получилось грамотно выделить хранилища и логику и отвязать от UI. А вот подход с хуками и привязкой к рендеру команде более подошел. Соответственно, вывод должен был быть "в нашей команде не получилось построить эффективную архитектуру работы с API, поэтому взяли готовую библиотеку".
Но это вовсе не значит, что у других команд тоже не получится без сторонних библиотек покрыть все эти кейсы, и в ряде случаев это будет эффективнее и удобнее, чем React Query. Например, последние 6-7 лет я использую подход, который базово описал здесь.
ручки описываются 1 раз
валидаторы запроса-ответа генерируются автоматически из TS-моделей
уникальные идентификаторы уже есть (название файла, а при динамических параметрах можно считать hash от request body)
каждая функция как объект обладает реактивным состоянием
getUser.state.isExecuting
,getUser.state.error
несложно сделать кеш, сравнивая
getUser.state.validTill - Date.now()
, то есть функция будет просто не вызывать запрос на бэк, а данные будут браться изstore.user
пока не истечет это время. И много других интересных преимуществ, подробнее описал там
Но кеширование на фронтенде всегда было плохой идеей, так как только бэк знает, когда обновились данные. И это должно настраиваться либо http-заголовками (чтобы браузер сразу брал из кеша), либо передачей version в запросе-ответе, если цель - оптимизировать запросы в БД (бэк, увидев совпадение version, отдаст данные из кеша, чем кардинально оптимизирует нагрузку). Либо если очень нужен фронтовый кеш - то его можно неинвазивно прикрутить практически к любому проекту через Service Worker
self.addEventListener("fetch")
и не менять ни одной строчки кода проекта, а бонусом - еще и оффлайн-режим и удобный неинвазивный механизм моков.И только в самом крайнем случае стоит вшивать кеши в код в императивном порядке (хотя примера придумать не могу). К слову, если данные класть в глобальный стор, а не в постраничный (который разрушается при переходе на другую страницу), то в классовом подходе кеш уже получится автоматически
useEffect(() => { api.getUser() }, []) return store.data ? <div className={api.getUser.state.isLoading && styles.isLoading}>{data}</div> : null;
так в фоне запрос вызовется, но компонент будет отрисован сразу из "кеша" - данных в сторе, поэтому ни скролл не сбросится, ни визуальная скорость приложения не пострадает. А сам запрос можно оптимизировать рецептами, которые выше привел.
Еще момент про SSR - его тоже несложно сделать без готовых библиотек. Как известно, useEffect на сервере не вызывается, но вот
useState(() => api.getUser())
вызовется. И если как выше писал api выделить в отдельный слой, то можно сделать// вызов всех апи из любой вложенности компонентов renderToString(App); // псевдокод, реальный можно в репо посмотреть await api.every(fn => !fn.state.executing); api.every(mock); res.send(renderToString(App).replace('SERVER_DATA', JSON.stringify(globalStore)));
Вот и весь SSR, поддерживающий любую вложенность - то есть не как в Next, где запросы можно вызывать только на страницах, а можно в 20 глубине вложенности (например в каком-то селекте) вызвать апи, и данные сохранятся в стор и сериализуются для клиента. При этом независимый слой апи полностью отвязан от UI и можно без проблем использовать в любой рендерилке. Если нужен CSR-only, можно по урлу отдавать с бэка пустой html (то есть в роутинге сделать 1 настройку -
ssr: true | false
для конкретного роута).В общем, если не хочется использовать готовые библиотеки типа React Query, то вполне можно придумать множество интересных и эффективных подходов, при этом MobX как полифилл реактивности очень помогает "дружить" независимые слои (сторы, апи, UI разных фреймворков). А чтобы в проекте были "строгие правила реализации" нужно не ставить 100500 сторонних библиотек с тоннами документации (это структурирует только микрочасть проекта), а должен быть грамотный техлид или архитектор, который будет синхронизировать команду.
Изначально наткнулся на статью из-за слова MobX, но про него тут ничего нет, поэтому в целом описал альтернативные подходы для решения проблем, описанных в статье)
dewolix Автор
07.08.2025 21:40Спасибо за очень развернутый комментарий и примеры! Мне тоже нравится MobX с классовым подходом тем, что можно чётко отделять логику и отображение
Вы правильно заметили, что подход с MobX включал в себя самостоятельное выстраивание флоу по работе с API и этот подход нам не подошёл. Мне хотелось подчеркнуть, что React Query решает множество проблем, которые мы пытались решить сами и что использование готовой библиотеки может помочь избежать риска совершить ещё больше ошибок. Да, всё можно выстроить грамотно и к способности это делать, безусловно, нужно стремиться. Просто в нашем случае было дешевле сменить подход
DmitryKazakov8
07.08.2025 21:40Я бы тоже хотел подчеркнуть, что "подход с MobX" - это не "подход с MobX", от него только makeAutoObservable как замена forceRender компонентов при изменении данных. 99.99% - это ваш код в ООП стиле и ваша архитектура, на которую Mobx не влияет. На нем можно писать очень по-разному - и без классов, и даже без plain объектов (как я показал на примере функций). Поэтому использование слова Mobx в статье - это только кликбейт, потому что никакой специфики, кроме как автоматических ререндеров UI, он не приносит в контексте статьи, а статья при этом и не про UI, а про совсем "далекую степь" - работу с API.
Отчего у читателя складывается неверное впечатление, будто Mobx хуже React Query в каких-то кейсах, но на самом деле совсем не так - это непересекающиеся вещи, которые нельзя сравнивать, и у которых совершенно разное назначение. То, что можно хранить данные в отдельном объекте и обернуть его в observable и то, что можно хранить данные внутри React Query и возвращать их через хук - это хоть и звучит похоже (объект там и там), но вещи совсем разные. Да, если пытаться использовать стейт вне рендера (mobx ни при чем, просто класс или объект) и стейт возвращаемый React Query внутри функции рендера, то будет дубляж данных, в этом плане их действительно можно противопоставить. Но противопоставление именно отдельного стейта и внутрикомпонентного стейта, с реактивностью это никак не связано.
Хотя React Query тоже кеш хранит глобально, и получается тройной конфликт (глобальный стор скрытый внутри React Query, его выдача в хуке внутри рендера, попытка вынести в независимый слой вне Реакта). Тут действительно можно сказать - либо React Query либо независимый слой хранилищ, потому что все вместе - большая каша. Но опять же, это не про реактивность)
Dmitry_Zotov
Что вам мешает добавить квери параметры в квери ключ? Этим вы решите проблему с одинаковыми ключами и разным количеством запрашиваемых элементов.
dewolix Автор
Квери-параметры в смысле и размер страницы в квери-ключ положить? Так тоже можно, да. Получатся два разных ключа `{ page_size: 10 }` и `{ page_size: 20 }`. В целом на проекте так и делаем. Про строки `-fixed-size`, `-paginated` я для примера явного разделения разных ключей привёл пример. А так да, и параметры туда можно развернуть, спасибо за замечание