Code splitting стратегии

Разделение по доменным модулям запросов

Одной из базовых стратегий является изоляция логики запросов по функциональным областям. Вместо централизованного файла с query-функциями используется модульная структура, где каждый домен (users, orders, auth, products) содержит собственные хуки и ключи запросов.

// features/users/queries.js
export const userKeys = {
  all: ['users'],
  detail: (id) => ['users', 'detail', id],
  list: (params) => ['users', 'list', params],
};

export async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}
// features/users/useUser.js
import { useQuery } from '@tanstack/react-query';
import { fetchUser, userKeys } from './queries';

export function useUser(id) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    enabled: !!id,
  });
}

Такой подход позволяет переносить целые фичи в отдельные чанки, если используется route-based или feature-based splitting.


Route-based code splitting и локализация Query логики

При использовании React Router или аналогичных систем маршрутизации логика TanStack Query может быть привязана к маршрутам и загружаться только при переходе на страницу.

import { lazy } from 'react';

const UsersPage = lazy(() => import('./pages/UsersPage'));
const OrdersPage = lazy(() => import('./pages/OrdersPage'));

Внутри страницы находится изолированный набор хуков:

// pages/UsersPage.jsx
import { useUsers } from '../features/users/useUsers';

export default function UsersPage() {
  const { data, isLoading } = useUsers();

  if (isLoading) return 'loading...';

  return data.map(u => <div key={u.id}>{u.name}</div>);
}

Ключевая идея: TanStack Query не требует глобальной регистрации запросов, поэтому их можно полностью переносить в lazy-loaded модули без потери функциональности.


Lazy-loading Query hooks и динамический импорт

Более агрессивная стратегия — динамический импорт самих query-функций или даже хуков.

import { useQuery } from '@tanstack/react-query';

export function useDynamicProduct(id) {
  return useQuery({
    queryKey: ['product', id],
    queryFn: async () => {
      const { fetchProduct } = await import('./api/fetchProduct');
      return fetchProduct(id);
    },
  });
}

Преимущество заключается в том, что код API-слоя попадает в отдельный chunk и не загружается до момента фактического использования запроса.


Разделение QueryClient и провайдеров по зонам приложения

В крупных приложениях возможна сегментация QueryClient на уровне поддеревьев React. Это позволяет отделять кэши и политики повторного использования данных.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const adminQueryClient = new QueryClient();
const publicQueryClient = new QueryClient();

function AdminApp() {
  return (
    <QueryClientProvider client={adminQueryClient}>
      <AdminRoutes />
    </QueryClientProvider>
  );
}

function PublicApp() {
  return (
    <QueryClientProvider client={publicQueryClient}>
      <PublicRoutes />
    </QueryClientProvider>
  );
}

Такой подход усиливает code splitting на уровне runtime-изоляции, а не только бандлинга.


Разделение по слоям: API, query keys, hooks

Эффективная архитектура достигается через явное разделение слоёв:

  • API слой (fetchers)
  • Query keys слой (структура ключей)
  • Hooks слой (useQuery wrappers)
// api/orders.js
export const getOrders = async () => {
  const res = await fetch('/api/orders');
  return res.json();
};

// queries/ordersKeys.js
export const ordersKeys = {
  all: ['orders'],
};

// hooks/useOrders.js
import { useQuery } from '@tanstack/react-query';
import { getOrders } from '../api/orders';
import { ordersKeys } from '../queries/ordersKeys';

export function useOrders() {
  return useQuery({
    queryKey: ordersKeys.all,
    queryFn: getOrders,
  });
}

Каждый слой может быть вынесен в отдельный chunk, особенно при использовании barrel-free импортов.


Avoid barrel files для улучшения tree-shaking

Баррельные файлы (index.js с re-export) часто ухудшают tree-shaking и приводят к тому, что весь feature попадает в основной bundle.

Проблемный вариант:

// features/users/index.js
export * from './useUser';
export * from './useUsers';

Правильнее:

import { useUser } from './features/users/useUser';

Это усиливает эффективность code splitting, особенно в Vite, Webpack и esbuild.


Query boundary splitting по компонентам

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

function UserCard({ id }) {
  const { data } = useUser(id);

  return <div>{data?.name}</div>;
}

function UsersList({ ids }) {
  return ids.map(id => <UserCard key={id} id={id} />);
}

Каждый компонент становится независимой единицей загрузки данных, особенно если используется lazy-rendering списков.


Preload + lazy execution границы

Code splitting в TanStack Query часто комбинируется с предзагрузкой, но важно разделять ответственность: preload не должен тянуть весь feature bundle.

const prefetchUser = (queryClient, id) => {
  queryClient.prefetchQuery({
    queryKey: ['users', id],
    queryFn: async () => {
      const { fetchUser } = await import('./api/fetchUser');
      return fetchUser(id);
    },
  });
};

Таким образом, предзагрузка данных не приводит к преждевременной загрузке UI-логики.


Feature flags как механизм ленивой загрузки запросов

Feature flags позволяют полностью исключать query-модули из бандла.

if (flags.enableAdvancedSearch) {
  import('./features/search/advancedQueries').then(mod => {
    mod.initAdvancedSearch(queryClient);
  });
}

Такая схема полезна для экспериментальных фич и A/B тестов, где запросы сами по себе являются тяжёлой частью функционала.


Micro-frontend подход и независимые query-контексты

В архитектуре микрофронтендов каждый модуль может иметь собственный QueryClient и собственный набор запросов.

// shell app
<QueryClientProvider client={sharedClient}>
  <RemoteApp />
</QueryClientProvider>

или полностью изолированный вариант:

// remote app
const client = new QueryClient();

<QueryClientProvider client={client}>
  <RemoteRoot />
</QueryClientProvider>

Это позволяет каждому микрофронтенду иметь собственный cache boundary и независимые code split chunks.


SSR и hydration-aware splitting

При серверном рендеринге важно разделять:

  • серверные query-функции
  • клиентские хуки
  • hydration-слой
export async function getServerSideProps() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

Клиентская часть может быть лениво загружена, не затрагивая серверный код запросов.


Granular splitting через query factories

Фабрики query позволяют переносить генерацию ключей и функций в отдельные модули, которые могут быть динамически импортированы.

export const createUserQueries = () => ({
  detail: (id) => ({
    queryKey: ['user', id],
    queryFn: async () => {
      const { fetchUser } = await import('../api/fetchUser');
      return fetchUser(id);
    },
  }),
});

Это даёт максимальную гибкость для chunking на уровне бизнес-логики.


Разделение по уровням критичности данных

Code splitting можно строить не только по фичам, но и по важности данных:

  • critical (авторизация, профиль)
  • secondary (списки, таблицы)
  • optional (аналитика, рекомендации)
export function useAnalytics() {
  return useQuery({
    queryKey: ['analytics'],
    queryFn: async () => {
      const { fetchAnalytics } = await import('./api/analytics');
      return fetchAnalytics();
    },
    staleTime: 1000 * 60 * 10,
  });
}

Некритичные запросы полностью выносятся в отдельные lazy chunks.


Разделение query-инвалидаций как часть splitting стратегии

Инвалидация кеша часто воспринимается как глобальная операция, но её тоже можно сегментировать.

queryClient.invalidateQueries({
  queryKey: ['users'],
  exact: false,
});

Если домены разделены, то invalidation-логика может быть локализована в feature-модулях, что уменьшает связность между chunks.


Изоляция side-effect слоёв запросов

Некоторые queryFn содержат побочные эффекты: логирование, трекинг, метрики. Эти слои можно выносить отдельно и загружать лениво.

export async function fetchUserWithMetrics(id) {
  const { trackRequest } = await import('../analytics/track');

  trackRequest('user_fetch');

  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

Таким образом, аналитический слой не попадает в основной bundle.


Комбинирование стратегий для максимального эффекта

На практике code splitting в TanStack Query строится не на одной технике, а на комбинации:

  • feature-based модули для границ бандла
  • dynamic import внутри queryFn
  • route-based lazy loading
  • строгие query key factories
  • отказ от barrel-экспорта
  • изоляция QueryClient по зонам

Эти механизмы усиливают друг друга и позволяют строить архитектуру, где запросы данных становятся частью управляемой системы загрузки кода, а не монолитной зависимостью.