javascript

Мой первый боевой проект: FSD, TanStack и как мы это дружили

  • воскресенье, 22 июня 2025 г. в 00:00:06
https://habr.com/ru/articles/920406/

Тут я расскажу о том, как я впервые с нуля поднимал проект на React, используя связку FSD, TanStack Router, TanStack Query и Effector — и как мы всё это далее подружили подружили или нет.

Сразу оговорюсь:

  • Проектом занимается команда из 4х разработчиков, но архитектурный старт, выбор технологий и базовая структура — легли на меня. Это был мой первый опыт в такой роли: отвечать не просто за компоненты или страницы, а за фундамент проекта.

  • А так же, это моя первая статья. Не претендую на истину в последней инстанции, но надеюсь, кому‑то мой опыт будет полезен и палками бить сильно не будете.

Описание проекта

Проект представляет собой админ‑панель, поддерживающую несколько более крупных продуктов. Основные функции: управление сертификатами, правами доступа, ролями, настройками.

В качестве UI‑библиотеки выбран Ant Design, кастомизированный под нужды проекта.

Формально мы переписывали существующее Angular 12-приложение, но фактически вся архитектура создавалась заново: маршрутизация, состояние, взаимодействие с API.

Почему именно TanStack Query, Router и Effector?

TanStack Query был выбран из‑за его мощности в работе с запросами: встроенное кеширование, повторные попытки запросов, фоновая подгрузка, бесконечная пагинация — всё это из коробки и без костылей. Благодаря кешу снижается количество реальных сетевых запросов (количество пользователей системы — примерно 9000 человек).

Некоторые фичи этого решения будут подробнее показаны ниже, в том числе пример с useInfiniteQuery.

Далее логично за TanStack Query пришёл и TanStack Router, потому что они отлично стакаются вместе: можно грузить данные прямо во время перехода между страницами, используя loader прямо в конфигурации маршрута. Также из коробки — различные фичи по типу beforeLoad и валидации параметров маршрута.

Effector же я подключил не для хранения данных с бэка — этим у нас занимается TanStack Query как основной асинхронный стейт‑менеджер.

Effector нужен для другого: упростить взаимодействие между разнесёнными компонентами, например, когда форма где‑то глубоко, а кнопка «Сохранить» — наверху. Как это работает — покажу ниже в статье.

Такой стек дал гибкость, контроль и внятное разделение зон ответственности между слоями — и при этом, как мне кажется, не перегружен лишним.

Структура проекта и организация окружения

Да да, в качестве архитектурного фундамента выбран Feature‑Sliced Design (FSD)

Структура проекта
Структура проекта

В слое app размещены глобальные компоненты, такие как Layout (Header, Sidebar) и провайдеры окружения.

— Провайдер для AntD‑темы
— Провайдер QueryClient;
— Провайдер маршрутизации.

Провайдеры

Каждый провайдер вынесен в отдельный компонент.

TanStack Query провайдер
TanStack Query провайдер
TanStack Router провайдер и сопутствующие файлы
TanStack Router провайдер и сопутствующие файлы

Для TanStack Router, выбор пал на code‑based подход в силу использования FSD.

Вообще с TanStack Router всё было крайне легко, очень удобный инструмент как мне показалось, только у ребят на соседнем проекте были проблемы с IDE когда включали строгую типизацию.

У меня же с ним только хорошие ассоциации даже тёплые воспоминания о роутинге Angular.

Работа с API

Бэкенд построен на Java, примерно 300+ эндпоинтов, описанных в OpenAPI спецификации. На первый взгляд, всё хорошо — можно сгенерировать типизированный клиент. Но:

  • Многие эндпоинты дублировались (getCard, getCard_1, getCard_2);

  • Контракты нестабильны: где‑то null, где‑то "0", где‑то массив из одного объекта;

  • ref‑поля не использовались должным образом — никакой вложенной типизации, по факту any.

Мы пробовали разные генераторы: openapi-typescript, orval, heyapi. Все упирались в несовместимость или избыточную сложность.

В итоге остановились на swagger-typescript-api — максимально простой и предсказуемый инструмент.

Плюсы:

  • шаблонная генерация без сюрпризов;

  • легко настраивается;

  • даёт основу, которую можно доработать вручную.

Минусы:

  • типизация очень поверхностная;

  • отсутствуют связи между сущностями;

  • нужно самостоятельно следить за согласованностью моделей и кешей.

(Примерно в этот момент захотелось написать свой генератор. Не для продакшена, а просто чтобы лучше понять, как всё это устроено.)

TanStack Query и ключи кеширования

Эта часть, пожалуй, самая важная — именно из‑за неё я и решил написать статью.
Как только мы внедрили TanStack Query, сразу встал вопрос: как вести ключи кеширования (queryKey)?

Первая реализация казалась жизнеспособной

На старте мы пошли по пути централизованного объекта QUERY_KEYS, где ключи определялись через ApiEntities и параметры методов API:

Первая реализация
Первая реализация

Идея была в том, чтобы все queryKey шли через один объект и были типизированы через параметры конкретных методов API. Формально это работало, но на практике:

  • Ключи были непрозрачными — без знания API или ApiEntities сложно понять, что за данные кешируются;

  • Параметры были слабо читаемы (...params), особенно если объект фильтров сложный;

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

В какой‑то момент мы поняли: такая схема слишком неудобна и неповоротлива.

Финальная реализация: фабрика ключей и разделение по сущностям

Покопавшись в интернетах и не найдя хороших материалов по теме организации queryKey в TanStack Query, я собрал следующий подход на основе накопленных наблюдений.

Поскольку ключи лучше передавать в виде массива строк:

const info = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList })
// или
const info = useQuery({ queryKey: ['todos', 'completed'], queryFn: fetchCompletedTodoList })
// и так может быть ещё несколько запросов с началом queryKey: ['todos', ...]

Так становится удобно инвалидировать кеш по этим значениям. Например, если я захочу инвалидировать весь кеш, связанный с todos, достаточно написать:

queryClient.invalidateQueries({ queryKey: ['todos'] })

И все ключи, начинающиеся с todos, будут инвалидированы. Это очень удобно.

Но я пошёл немного дальше и сделал фабрику генерации ключей:

export function createQueryKey<T extends unknown[]>(namespace: string, ...args: T) {
  return [namespace, ...args] as const;
}

В итоге для каждого типа запросов я завёл константу с ключами:

import { createQueryKey } from 'shared/api/queryKeys/createQueryKey';

export const userQueryKeys = {
  all: () => createQueryKey('users'),
  byId: (id: string) => createQueryKey('users', id),
  card: (id: string) => createQueryKey('users', id, 'card'),
  byFilters: (filters: UserFilters) =>
    createQueryKey('users', 'filters', JSON.stringify(filters)),
};

Это позволяет удобно использовать ключи в хуках с запросами:

export const useUserCard = (id: string, enabled = true) =>
  useQuery({
    queryKey: userQueryKeys.card(id),
    queryFn: () => getUserCard({ id }),
    enabled: enabled && !!id,
  });

А инвалидация теперь выглядит так:

queryClient.invalidateQueries({ queryKey: userQueryKeys.all() });
queryClient.invalidateQueries({ queryKey: reestrQueryKeys.byComplexFilter({}) });

Если мы кешируем значение по фильтрам:

export type ReestrUsersComplexFilter = {
  status?: string;
  roles?: string[];
  dateFrom?: string;
  dateTo?: string;
  search?: string;
};

byComplexFilter: (filters: ReestrUsersComplexFilter) =>
  createQueryKey('reestrUsers', 'complex', JSON.stringify(filters));

Для сложных объектов используйте JSON.stringify — так ключ будет однозначным (если порядок ключей всегда одинаков).

После всех этих нововведений, работа с ключами в проекте стала куда более предсказуемой — код, структура, подходы к запросам и кешированию теперь заданы довольно чётко. И легко инвалидировать кеш как локально по определённым запросам, так и глобально по модулю.

Effector и зачем я его вообще взял

Да, TanStack Query сам по себе является асинхронным стейт‑менеджером, но Effector здесь не просто так — он решает конкретную задачу:

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

Самый простой пример:
Я использую AntD форму, которая расположена глубоко в компоненте. А кнопку «Сохранить» хочу разместить в Header родительского роута — или вообще вынести в другой виджет.

То есть:

  • Кнопка «Сохранить» — в одном месте;

  • Вызов мутации формы — в другом, внутри формы.

Передавать коллбеки пропсами — невозможно. Использовать контекст — громоздко и неявно.
А вот Effector дал простой и удобный способ: создать event, на который подписана форма, и триггерить его из кнопки.

Таким образом, Effector стал связующим звеном между UI‑блоками, особенно в сложных сценариях с вложенными роутами и формами.

Итоги

TanStack Router и TanStack Query отлично работают в связке поскольку они в одной набирающей популярность экосистеме TanStack. Их API и подходы к состоянию, загрузке данных и потоку данных очень хорошо сочетаются.

Один из главных плюсов: возможность запрашивать данные прямо в конфигурации маршрутов.

const postsLayoutRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: 'posts',
  loader: ({ context: { queryClient } }) =>
    queryClient.ensureQueryData(postsQueryOptions),
}).lazy(() =>
  import('./posts.lazy').then((d) => d.Route)
)

const postsIndexRoute = createRoute({
  getParentRoute: () => postsLayoutRoute,
  path: '/',
  component: PostsIndexRouteComponent,
})

Загрузка данных заранее, до рендера компонента, происходит типобезопасно, синхронно с маршрутизацией и без лишних ручных проверок.

Сам по себе TanStack Query — это мощный инструмент для работы с данными. Он покрывает практически все потребности, включая:

  • кеширование,

  • повторные попытки,

  • загрузку по требованию,

  • фоновое обновление,

  • пагинацию и бесконечную прокрутку

// Простой пример с бесконечной пагинацией
const {
  data,
  error,
  fetchNextPage,
  hasNextPage,
  isFetching,
  isFetchingNextPage,
  status,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

А если бэкенд не предоставляет nextCursor, и приходится рассчитывать это вручную — TanStack Query тоже справляется

Мой пример из проекта
Мой пример из проекта

И всё это без надстроек в виде сложных компонентов для виртуального скролла и тд.

Важно сказать что не стоит пытаться класть результаты запроса из TanStack Query в сторы Effector, это плохая идея, если сильно хочется то лучше взять Farfetched, но тут есть уже свои моменты.