Чому ми відмовилися від Redux / RTK Query на користь TanStack Query + Zustand: досвід реального проєкту

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

За останні п’ять років екосистема React пройшла довгий і болісний шлях — від епохи, коли абсолютно все пакували в один гігантський Redux Store, до усвідомлення простої істини: не весь стан у застосунку має однакову природу.

Якщо ви хоч раз намагалися покласти результати REST API або GraphQL-запитів у класичний Redux, ви напевно знаєте цей біль: створення десятків слайсів, нескінченні редюсери для прапорців isLoading та error, ручне проектування кешування й постійна боротьба з «застарілими» даними.

У цій статті ми розберемо, чому наша команда прийняла рішення повністю випиляти Redux Toolkit (RTK) та RTK Query з продакшн-проєкту, як ми організували міграцію на дует TanStack Query (React Query) + Zustand, і чому цей підхід виявився значно ефективнішим як для продуктивності застосунку, так і для комфорту розробників.

Фундаментальна помилка: розмиття межі між Server State та Client State

Головна причина, чому Redux-архітектура починає «тріщати по швах» у масштабних SPA, полягає у змішуванні двох абсолютно різних типів даних.

Server state: це дані, які вам не належать. Вони зберігаються на віддаленому сервері, доступні через асинхронні API, можуть застарівати в будь-який момент і вимагають кешування, дедуплікації запитів, повторних спроб та інвалідації.

Client state: це дані, які живуть суто в пам’яті браузера. Наприклад: відкритий чи згорнутий сайдбар, поточна тема оформлення, стан покрокової форми чи значення введеного фільтра перед відправкою.

Коли ви намагаєтеся керувати серверними даними за допомогою звичайного менеджера станів (на кшталт класичного Redux), ви змушені власноруч писати логіку кешування, синхронізації та обробки асинхронності. Ви створюєте складну ілюзію того, що серверні дані є локальними, що неминуче призводить до появи тонких багів і роздутого boilerplate-коду.

Навіщо мігрувати з Redux, якщо є RTK Query?

Це перше природне запитання, яке виникає у кожного синьйор-розробника. Якщо проблему серверного стану вирішує RTK Query, то навіщо відмовлятися від всієї екосистеми Redux і тягнути в проєкт дві нові бібліотеки?

Відповідь криється в архітектурному оверхеді, гнучкості та підході до декларації коду.

1. Конфігурація та глобальні провайдери

RTK Query суворо прив’язаний до монолітного Redux Store. Щоб додати новий API-ендпоінт, ви змушені конфігурувати store.ts, підключати custom middleware, обробляти reducerPath та реєструвати слайси.

Натомість TanStack Query абсолютно агностичний. Він працює через контекст, не вимагає прив’язки до глобального менеджера станів і дозволяє описувати запити максимально атомарно — саме там, де вони потрібні.

2. Інвалідація кешу: Теги проти Ключів

В RTK Query інвалідація кешу побудована на системі тегів (providesTags та invalidatesTags). На невеликих проєктах це працює чудово, але в масштабному застосунку з сотнями взаємопов’язаних ендпоінтів менеджмент тегів перетворюється на заплутану павутину.

TanStack Query використовує просту та прозору концепцію queryKey (масив за аналогією з масивом залежностей у useEffect). Ви можете інвалідувати кеш як точково, так і цілими ієрархічними групами:

// Інвалідувати абсолютно всі запити, що стосуються користувачів
queryClient.invalidateQueries({ queryKey: ['users'] }); 

// Інвалідувати тільки конкретного користувача з ID 42
queryClient.invalidateQueries({ queryKey: ['users', 42] });

Практичне порівняння: Подивіться на код

Давайте порівняємо реалізацію одного й того самого функціоналу в обох підходах.

Приклад 1: Робота з Server State (Отримання та оновлення даних)

Підхід на Redux Toolkit + RTK Query:

// apiSlice.ts — Обов'язкова конфігурація та централізована декларація
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUserById: builder.query<User, string>({
      query: (id) => `users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
    updateUserProfile: builder.mutation<User, Partial<User> & { id: string }>({
      query: ({ id, ...patch }) => ({
        url: `users/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
    }),
  }),
});

export const { useGetUserByIdQuery, useUpdateUserProfileMutation } = userApi;

Підхід на TanStack Query:

// useUser.ts — Атомарний, чистий та ізольований кастомний хук
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchUser, updateUserApi } from './api';

export const useUser = (userId: string) => {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // Дані вважаються свіжими 5 хвилин
  });
};

export const useUpdateUser = () => {
const queryClient = useQueryClient();

return useMutation({
    mutationFn: updateUserApi,
    onSuccess: (updatedUser) => {
      // Пряме оновлення або точкова інвалідація
      queryClient.invalidateQueries({ queryKey: ['users', updatedUser.id] });
    },
  });

};

Зверніть увагу, наскільки легше читається та тестується код у другому варіанті. Вам не потрібно тягнути складні типи та прив’язуватися до жорстких абстракцій RTK Query.

Приклад 2: Робота з Client (UI) State

Тепер подивимося на легкий UI-стан — наприклад, керування станом модального вікна чи бічної панелі.

Реалізація на Redux Toolkit:

// sidebarSlice.ts
import { createSlice } from '@reduxjs/toolkit';

const sidebarSlice = createSlice({
  name: 'sidebar',
  initialState: { isOpen: false },
  reducers: {
    toggleSidebar: (state) => {
      state.isOpen = !state.isOpen;
    },
  },
});

export const { toggleSidebar } = sidebarSlice.actions;
export default sidebarSlice.reducer;

// Component.tsx
import { useSelector, useDispatch } from 'react-redux';
import { toggleSidebar } from './sidebarSlice';

export const Navigation = () => {
  const dispatch = useDispatch();
  const isOpen = useSelector((state: RootState) => state.sidebar.isOpen);

  return <button onClick={() => dispatch(toggleSidebar())}>Toggle ({isOpen})</button>;
};

Реалізація на Zustand:

// useSidebarStore.ts
import { create } from 'zustand';

interface SidebarStore {
  isOpen: boolean;
  toggleSidebar: () => void;
}

export const useSidebarStore = create<SidebarStore>((set) => ({
  isOpen: false,
  toggleSidebar: () => set((state) => ({ isOpen: !state.isOpen })),
}));

// Component.tsx — Прямий доступ без Provider, useDispatch чи useSelector
import { useSidebarStore } from './useSidebarStore';

export const Navigation = () => {
  const { isOpen, toggleSidebar } = useSidebarStore();
  
  return <button onClick={toggleSidebar}>Toggle ({isOpen})</button>;
};

Чому Zustand перемагає Redux у Client State:

  1. Не потрібно загортати додаток у Provider, Zustand працює поза контекстом React. Ви можете зчитувати або змінювати стан навіть у звичайних JS/TS файлах (наприклад, всередині API-інтерцепторів Axios чи службових утиліт).
  2. Жодних типів діспатчерів, екшнів та складних конфігурацій стору.
  3. Zustand дозволяє точково підписуватися лише на ті поля стору, які потрібні конкретному компоненту, запобігаючи зайвим ререндерам:

const isOpen = useSidebarStore((state) => state.isOpen);

Архітектурні нюанси та потенційні ризики:

Звісно, срібної кулі в веб-розробці не існує. Відмова від суворого Redux на користь більш вільних інструментів вимагає від команди високої дисципліни.

Оскільки Zustand дає повну свободу, виникає спокуса змінювати стан прямо з компонентів через set(). Це шлях до спагетті коду.

Щоб зберегти кодову базу чистою, ми запровадили суворі проєктні правила:

  1. Будь-яка зміна стану повинна відбуватися тільки через функції-екшени, визначені всередині самого стору. Компоненти мають лише викликати ці функції.
  2. Замість створення одного гігантського Zustand стору ми створюємо маленькі, вузькоспеціалізовані стори (useAuthStore, useFilterStore, useThemeStore).

А що робити з DevTools?

Прихильники Redux часто хваляться потужним Redux DevTools з підтримкою Time Travel Debugging.

У нашому новому стеку інструменти налагодження виявилися навіть зручнішими:

  1. TanStack Query DevTools дають вичерпну картину про стан усіх мережевих запитів, вміст кешу, статус застарівання (stale, fresh, fetching) та дозволяють вручну тригерити інвалідацію прямо з UI.
  2. Zustand чудово підключається до стандартного браузерного розширення Redux DevTools за допомогою вбудованого middleware:
import { devtools } from 'zustand/middleware';

export const useFilterStore = create(
  devtools((set) => ({
    /* ... */
  }))
);

Для яких сценаріїв цей стек менше підходить?

Слід чітко розуміти межі застосування:

  1. Якщо ви розробляєте графічний чи текстовий редактор, веб-САПР, або складний фінансовий термінал з тисячами взаємопов’язаних синхронних обчислень на клієнті та необхідністю повноцінного Undo/Redo, централізований редюсерний підхід Redux може виявитися доречнішим.
  2. Якщо у вас величезний legacy-проєкт із сотнями тисяч рядків коду на Redux, переписати його ради «модного стеку» недоцільно з точки зору бізнесу. У такому разі краще поступово впроваджувати RTK Query.

Результати міграції:

  1. Видалення Redux-моноліту, стандартних редюсерів та складних типів дозволило позбутися тисяч рядків чистого boilerplate. Обсяг коду скоротився приблизно на 30%.
  2. Новим членам команди більше не потрібно годинами розбиратися у складних ланцюжках редюсерів та middleware. TanStack Query інтуїтивно зрозумілий будь-кому, хто працював з асинхронним JavaScript.
  3. Завдяки вбудованим механізмам дедуплікації запитів, фоновому оновленню (refetchOnWindowFocus) та грамотній роботі з кешем, навантаження на сервер зменшилося, а швидкість відгуку UI для користувачів зросла.

Чітке розділення відповідальності між TanStack Query (для серверних даних) та Zustand (для локального UI-стану) — це сучасний, гнучкий та прагматичний стандарт для розробки React-застосунків будь-якої складності.

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Теж використовую подібний підхід. Але хочу додати що рядок

import { fetchUser, updateUserApi } from ’./api’;

І в статті ця частина не розглядається. І це очевидно, це банальна директорія яке живе в будь-якому проєкті, всі ми маємо уявлення що там знаходиться.

Але є рішення і для цієї межі. Пропоную розглянути мою бібліотеку StitchAPI.
Написав статтю про те що це, і чому воно краще ніж обгортки над fetch у api.ts.
dou.ua/forums/topic/60943

Вже давно не використовував Redux на проектах, tanstack забрав усю логіку по суті. Але і zustand ніколи не використовував, імхо нативний реакт-контекст чудово справляється з усіма задачами, головне розділяти на менші контексти, використовувати тільки в тих місцях де потрібно і піднімати його вище, якщо треба більше покриття.

Підписатись на коментарі