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

от автора

Тут я расскажу о том, как я впервые с нуля поднимал проект на 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, но тут есть уже свои моменты.


ссылка на оригинал статьи https://habr.com/ru/articles/920406/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *