Тут я расскажу о том, как я впервые с нуля поднимал проект на 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 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, но тут есть уже свои моменты.
Vitaly_js
Из текста не очень понял несколько вещей.
Вот для чего нужна вот эта вундервафля?
На вид, все что она делает заменяет это:
На это:
Это ее единственная функция?
Плюс, не очень понял как пользоваться userQueryKeys
Например, у нас есть
userQueryKeys.card
который состоит из'users', id, 'card'
на вид это выглядит так, что совершенно валидно инвалидировать все карточки использовав предикат:Но тогда возникает вопрос, а не удобнее было бы семантически группировать ключи? Что бы все, что относится к card было в одном месте.
И тут возникает третий вопрос, а почему решили не рассматривать подход, который предлагают рассмотреть в доке к https://tanstack.com/query/latest/docs/framework/react/guides/query-keys#further-reading, а именно https://github.com/lukemorales/query-key-factory
VadimLatypov Автор
Да, это просто по факту меняет вид написания, на вид стало лучше, как мне кажется
userQueryKeys, в данном случае централизует, ключи по категории например user
Если рассматривать именно на этом коде
то вызывая
мы инвалидруем только карточку юзера с id 543, а если будет
то всю бизнес сущность юзера, как каждого, так и каждого по id, так и карточку каждого пользователя, а ещё по фильтру
запись коротка и радует глаз, опять же как мне кажется
А вот такой либы https://github.com/lukemorales/query-key-factory, у нас нет в нексусе, из-за особенностей сферы разработки, а базовый подход слишком сильно развязывает руки разработчику и децентрализует ключи, опять же как мне кажется.
Повторюсь это мой первый опыт в таком деле, возможно далее если будет необходимость в сложной инвалидации оно может разростись, но для начала, по читаемости это очень неплохой вриант