Search
Write a publication
Pull to refresh
147.51
KTS
Создаем цифровые продукты для бизнеса

Удалить полпроекта: как мы переписывали MobX‑сторы на React Query в большом Next.js‑проекте

Reading time21 min
Views437

Привет. Я Дима Рагозин, фронтенд-разработчик в 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. Это делается в несколько шагов:

    1. Все приложение оборачивается в QueryClientProvider.

    2. Создается новый QueryClient при каждом запросе страницы (мы не хотим, чтобы данные между запросами смешивались).

    3. С использованием созданного QueryClient запрашиваются данные.

    4. 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, VK_WIDGET_EVENTS_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 в свой проект. В комментариях предлагаю вам рассказать, что вы думаете об этой библиотеке, и был ли у вас подобный опыт изменения принципов работы с запросами.

А познакомиться с другими полезными библиотеками вы можете в статьях моих коллег-фронтендеров:

Tags:
Hubs:
+9
Comments0

Articles

Information

Website
kts.tech
Registered
Founded
Employees
101–200 employees
Location
Россия