Redux Toolkit и RTK Query: От основ до PRO на TypeScript

Практический курс по современному управлению состоянием в React с использованием TypeScript и Tailwind CSS. Вы пройдете путь от настройки Store до реализации сложной логики кэширования и мутаций данных с помощью RTK Query.

Настройка Redux Store, типизация хуков и интеграция с React на TypeScript

Настройка Redux Store, типизация хуков и интеграция с React на TypeScript

Добро пожаловать в курс Redux Toolkit и RTK Query: От основ до PRO на TypeScript. Это первая статья нашего цикла, в которой мы заложим фундамент для всех будущих приложений. Мы не просто настроим Redux, мы сделаем это правильно: с полной типизацией, использованием современных хуков и интеграцией в React-приложение.

Redux Toolkit (RTK) — это официальный, рекомендованный способ написания логики Redux. Он решает множество проблем «старого» Redux: убирает необходимость в огромном количестве шаблонного кода (boilerplate), упрощает настройку хранилища и делает работу с TypeScript интуитивно понятной.

Установка зависимостей

Прежде чем писать код, нам нужно установить необходимые пакеты. Мы предполагаем, что у вас уже развернут React-проект на TypeScript (например, через Vite или Create React App).

Выполните следующую команду в терминале:

npm install @reduxjs/toolkit react-redux

Здесь:

  • @reduxjs/toolkit — основная библиотека, включающая методы для создания слайсов, стора и RTK Query.
  • react-redux — библиотека для связывания Redux с компонентами React.

Архитектура Redux Toolkit

В современном Redux мы оперируем понятием Slice (срез). Слайс — это коллекция логики редюсера и действий (actions) для одной конкретной функции вашего приложения (например, авторизация, корзина товаров или счетчик).

Шаг 1: Создание Слайса (Slice)

Создадим простую функциональность счетчика, чтобы продемонстрировать типизацию. Создайте файл src/store/features/counter/counterSlice.ts.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

// 1. Описываем интерфейс состояния
interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

// 2. Задаем начальное состояние
const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    // Redux Toolkit позволяет писать "мутирующую" логику в редюсерах.
    // На самом деле он не мутирует состояние, а использует библиотеку Immer
    // под капотом, которая обеспечивает неизменяемость.
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // Пример экшена с полезной нагрузкой (payload)
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

// Генерируем экшены автоматически
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// Экспортируем редюсер для добавления в store
export default counterSlice.reducer;

Обратите внимание на PayloadAction<number>. Это дженерик-тип, который гарантирует, что в action.payload придет именно число, а не строка или объект. Это и есть сила TypeScript.

Шаг 2: Настройка Store

Теперь создадим само хранилище. Создайте файл src/store/store.ts.

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    // Здесь будут добавляться другие редюсеры
  },
});

// Выводим типы `RootState` и `AppDispatch` из самого хранилища
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Функция configureStore автоматически настраивает инструменты разработчика (Redux DevTools) и добавляет полезные middleware (например, для проверки на случайные мутации).

Ключевой момент здесь — экспорт типов RootState и AppDispatch. Мы не пишем их вручную, мы просим TypeScript вывести их на основе созданного объекта store. Это гарантирует, что типы всегда будут актуальны при изменении редюсеров.

Шаг 3: Типизация хуков

В обычном JavaScript мы бы использовали useDispatch и useSelector напрямую из библиотеки react-redux. Однако в TypeScript это неудобно: useSelector не знает структуру нашего стейта, а useDispatch не знает о thunk-экшенах.

Лучшая практика — создать типизированные версии этих хуков. Создайте файл src/store/hooks.ts.

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// Используйте эти хуки во всем приложении вместо обычных `useDispatch` и `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Теперь, когда вы будете использовать useAppSelector, TypeScript будет подсказывать вам поля вашего состояния (например, state.counter.value).

Шаг 4: Провайдер Store

Чтобы React увидел Redux, нужно обернуть приложение в компонент Provider. Обычно это делается в корневом файле, например main.tsx или index.tsx.

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store/store';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
);

Интеграция в компонент

Теперь соберем всё вместе. Создадим компонент, который использует наши типизированные хуки и стилизован с помощью Tailwind CSS.

import React, { useState } from 'react';
import { useAppSelector, useAppDispatch } from './store/hooks';
import { increment, decrement, incrementByAmount } from './store/features/counter/counterSlice';

export default function Home() {
  // Используем типизированный селектор
  const count = useAppSelector((state) => state.counter.value);
  // Используем типизированный диспатч
  const dispatch = useAppDispatch();

  const [incrementAmount, setIncrementAmount] = useState<string>('2');
  const amountValue = Number(incrementAmount) || 0;

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <div className="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
        <h1 className="text-2xl font-bold text-center mb-6 text-gray-800">
          Redux Toolkit Counter
        </h1>

        <div className="flex items-center justify-center space-x-6 mb-8">
          <button
            className="w-12 h-12 flex items-center justify-center bg-red-500 hover:bg-red-600 text-white rounded-full text-2xl transition-colors"
            aria-label="Decrement value"
            onClick={() => dispatch(decrement())}
          >
            -
          </button>

          <span className="text-4xl font-mono font-bold text-gray-700">
            {count}
          </span>

          <button
            className="w-12 h-12 flex items-center justify-center bg-green-500 hover:bg-green-600 text-white rounded-full text-2xl transition-colors"
            aria-label="Increment value"
            onClick={() => dispatch(increment())}
          >
            +
          </button>
        </div>

        <div className="flex flex-col space-y-4">
          <div className="flex space-x-4">
            <input
              className="flex-1 border-2 border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500"
              value={incrementAmount}
              onChange={(e) => setIncrementAmount(e.target.value)}
              type="number"
            />
            <button
              className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition-colors"
              onClick={() => dispatch(incrementByAmount(amountValue))}
            >
              Add Amount
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

Разбор кода компонента

  1. useAppSelector: Мы передаем функцию (state) => state.counter.value. Благодаря типизации, IDE знает, что у state есть поле counter, а у него — поле value. Если вы ошибетесь в названии, TypeScript немедленно подсветит ошибку.
  2. useAppDispatch: Возвращает функцию dispatch, которая принимает только валидные экшены. Если вы попробуете отправить incrementByAmount('пять') (строку вместо числа), TypeScript укажет на несоответствие типа PayloadAction<number>.

Почему это важно?

Использование Redux Toolkit в связке с TypeScript дает нам:

  • Безопасность: Вы не можете обратиться к несуществующему полю стейта.
  • Скорость разработки: Автодополнение кода работает великолепно.
  • Читаемость: Структура кода становится стандартизированной и понятной любому разработчику, знакомому с RTK.

В следующей статье мы углубимся в работу с асинхронными данными и рассмотрим, как RTK Query меняет правила игры при работе с API.

Архитектура данных: создание слайсов, редьюсеров и экшенов в Redux Toolkit

Архитектура данных: создание слайсов, редьюсеров и экшенов в Redux Toolkit

В предыдущей статье мы настроили базовое хранилище (Store) и типизировали хуки. Теперь пришло время углубиться в сердце Redux Toolkit — функцию createSlice. Именно здесь происходит магия, превращающая рутинное написание кода в элегантный и поддерживаемый процесс.

Мы перейдем от простого счетчика к более сложной структуре данных — списку задач (Todo List). Это позволит нам разобрать работу с массивами, объектами, подготовку данных (prepare callback) и принцип иммутабельности.

Анатомия Слайса (Slice)

В классическом Redux нам приходилось создавать три разных сущности: константы типов экшенов (ACTION_TYPE), генераторы экшенов (action creators) и функцию-редьюсер. Это приводило к размазыванию логики по разным файлам.

Слайс в Redux Toolkit объединяет эти концепции. Вы описываете состояние и то, как оно меняется, а библиотека сама генерирует остальное.

Проектирование состояния

Начнем с создания нового файла src/store/features/todos/todosSlice.ts. Первым делом определим типы.

import { createSlice, PayloadAction, nanoid } from '@reduxjs/toolkit';

// Описываем структуру одной задачи
export interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

// Описываем стейт слайса
interface TodosState {
  list: Todo[];
}

const initialState: TodosState = {
  list: [],
};

Здесь мы используем nanoid — встроенную в RTK утилиту для генерации уникальных строковых ID. Она легче и быстрее, чем UUID.

Магия Immer и мутабельность

Одно из главных правил Redux: состояние должно быть неизменяемым (immutable). В классическом Redux для добавления элемента в массив нам приходилось писать:

// Классический Redux (НЕ RTK)
return {
  ...state,
  list: [...state.list, newTodo]
};

Redux Toolkit использует библиотеку Immer. Она оборачивает состояние в Proxy-объект. Вы можете писать код так, будто вы изменяете состояние напрямую (мутируете его), а Immer отследит изменения и создаст новую копию состояния безопасным способом.

Создадим слайс с базовыми действиями:

export const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    // Удаление задачи
    removeTodo: (state, action: PayloadAction<string>) => {
      // Мы просто фильтруем массив, присваивая новый результат
      state.list = state.list.filter((todo) => todo.id !== action.payload);
    },
    // Переключение статуса
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.list.find((t) => t.id === action.payload);
      if (todo) {
        // Мы меняем поле напрямую!
        todo.completed = !todo.completed;
      }
    },
  },
});

Обратите внимание на toggleTodo. Мы нашли объект и просто изменили его свойство completed. Без RTK это было бы грубой ошибкой, но здесь это абсолютно легально и правильно.

Продвинутая техника: prepare callback

Частая проблема новичков: где генерировать уникальный ID или текущую дату при создании новой задачи? В компоненте или в редьюсере?

  1. В редьюсере нельзя: Редьюсеры должны быть чистыми функциями. nanoid() или Date.now() выдают разные значения при каждом вызове, что нарушает чистоту.
  2. В компоненте нежелательно: Компонент должен отвечать за UI, а не за логику формирования данных.

Решение от RTK — колбэк prepare. Он позволяет трансформировать аргументы, переданные в экшен, до того, как они попадут в редьюсер.

Добавим экшен addTodo в наш слайс:

    addTodo: {
      // reducer - выполняет изменение стейта
      reducer: (state, action: PayloadAction<Todo>) => {
        state.list.push(action.payload);
      },
      // prepare - подготавливает payload
      prepare: (text: string) => {
        return {
          payload: {
            id: nanoid(),
            text,
            completed: false,
          },
        };
      },
    },

Теперь, когда мы будем вызывать dispatch(addTodo('Купить молоко')) в компоненте, RTK автоматически вызовет prepare, сгенерирует ID и сформирует полный объект Todo.

Полный код слайса

Соберем всё вместе и экспортируем необходимые части.

import { createSlice, PayloadAction, nanoid } from '@reduxjs/toolkit';

export interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodosState {
  list: Todo[];
}

const initialState: TodosState = {
  list: [],
};

export const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: {
      reducer: (state, action: PayloadAction<Todo>) => {
        state.list.push(action.payload);
      },
      prepare: (text: string) => {
        return {
          payload: {
            id: nanoid(),
            text,
            completed: false,
          },
        };
      },
    },
    removeTodo: (state, action: PayloadAction<string>) => {
      state.list = state.list.filter((todo) => todo.id !== action.payload);
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.list.find((t) => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
  },
});

export const { addTodo, removeTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;

Не забудьте подключить этот редьюсер в store.ts (который мы создали в прошлом уроке):

// src/store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';
import todosReducer from './features/todos/todosSlice'; // Импортируем

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer, // Добавляем в стор
  },
});

// ... типы RootState и AppDispatch остаются прежними

Создание UI компонента

Теперь создадим компонент TodoList, который будет использовать наш новый слайс. Мы будем использовать Tailwind CSS для стилизации.

Создайте файл src/components/TodoList.tsx.

import React, { useState } from 'react';
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { addTodo, removeTodo, toggleTodo } from '../store/features/todos/todosSlice';

export default function TodoList() {
  const [text, setText] = useState('');
  const todos = useAppSelector((state) => state.todos.list);
  const dispatch = useAppDispatch();

  const handleAdd = () => {
    if (text.trim()) {
      // Обратите внимание: мы передаем только текст,
      // а ID и completed генерируются в prepare callback
      dispatch(addTodo(text));
      setText('');
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') handleAdd();
  };

  return (
    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow-xl">
      <h2 className="text-2xl font-bold mb-4 text-gray-800">Список задач</h2>

      <div className="flex gap-2 mb-6">
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Что нужно сделать?"
          className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          onClick={handleAdd}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
        >
          Добавить
        </button>
      </div>

      <ul className="space-y-3">
        {todos.map((todo) => (
          <li
            key={todo.id}
            className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
          >
            <div
              className="flex items-center gap-3 cursor-pointer flex-1"
              onClick={() => dispatch(toggleTodo(todo.id))}
            >
              <div className={`w-5 h-5 border-2 rounded flex items-center justify-center ${
                todo.completed ? 'bg-green-500 border-green-500' : 'border-gray-400'
              }`}>
                {todo.completed && (
                  <span className="text-white text-xs font-bold">✓</span>
                )}
              </div>
              <span className={`text-lg ${todo.completed ? 'line-through text-gray-400' : 'text-gray-700'}`}>
                {todo.text}
              </span>
            </div>

            <button
              onClick={() => dispatch(removeTodo(todo.id))}
              className="text-red-500 hover:text-red-700 p-2"
              aria-label="Delete todo"
            >
              ✕
            </button>
          </li>
        ))}
        {todos.length === 0 && (
          <p className="text-center text-gray-400 italic">Задач пока нет. Добавьте первую!</p>
        )}
      </ul>
    </div>
  );
}

Разбор ключевых моментов

  1. Типизация useAppSelector: Когда мы пишем state.todos.list, TypeScript точно знает, что это массив объектов Todo. Если мы попробуем обратиться к todo.title вместо todo.text, компилятор выдаст ошибку.
  2. Диспатч экшенов: Мы вызываем dispatch(toggleTodo(todo.id)). Функция toggleTodo была сгенерирована автоматически из имени слайса (todos) и имени редьюсера (toggleTodo), создавая тип экшена todos/toggleTodo.

Резюме

В этой статье мы разобрали архитектуру данных в Redux Toolkit:

  • Слайсы (Slices) заменяют собой разрозненные файлы экшенов и редьюсеров.
  • Immer позволяет писать логику изменения состояния так, будто мы мутируем объекты, сохраняя при этом иммутабельность.
  • prepare callback — мощный инструмент для выноса "грязной" логики (генерация ID) из компонентов и сохранения чистоты редьюсеров.

Теперь у нас есть полностью рабочее приложение с синхронной логикой. Но что делать, если задачи нужно загружать с сервера? В следующей статье мы разберем асинхронные операции и createAsyncThunk.

Взаимодействие UI и Store: выборка данных и диспетчеризация событий в компонентах

Взаимодействие UI и Store: выборка данных и диспетчеризация событий в компонентах

Мы уже проделали большую работу: настроили Redux Store, типизировали хуки и создали слайс для списка задач. У нас есть «двигатель» (Store) и «топливо» (данные и логика в слайсе), но машина не поедет, пока мы не соединим их с «рулем» и «приборной панелью» — нашим пользовательским интерфейсом (UI).

В этой статье мы научимся правильно извлекать данные из хранилища, отправлять команды на изменение состояния и реализуем логику фильтрации задач. Мы будем использовать наши кастомные хуки useAppSelector и useAppDispatch, созданные в первом уроке.

Поток данных в Redux

Прежде чем писать код, важно визуализировать, как данные перемещаются в приложении. Redux использует однонаправленный поток данных (Unidirectional Data Flow).

  1. View (UI): Компонент отображает данные.
  2. Dispatch: Пользователь совершает действие (клик), компонент отправляет (dispatch) экшен.
  3. Reducer: Слайс перехватывает экшен и обновляет состояние.
  4. Selector: Компонент замечает изменение данных через селектор и перерисовывается.

Чтение данных: useAppSelector

Для получения данных из Redux используется хук-селектор. В нашем случае это типизированный useAppSelector.

Что такое селектор?

Селектор — это чистая функция, которая принимает всё состояние приложения (RootState) и возвращает из него какую-то часть.

// Простой селектор: возвращает список задач
const selectTodos = (state: RootState) => state.todos.list;

// Использование в компоненте
const todos = useAppSelector(selectTodos);

Вы также можете писать селектор инлайн (прямо внутри хука), что часто удобнее для простых операций:

const todos = useAppSelector((state) => state.todos.list);

Правила рендеринга

useAppSelector — это умный хук. Он автоматически подписывается на обновления Redux Store. Однако компонент будет перерисован (re-render) только тогда, когда значение, возвращаемое селектором, изменится.

Сравнение происходит по ссылке (===).

  • Если селектор возвращает примитив (число, строку, булево значение) — ререндер будет только при смене значения.
  • Если селектор возвращает объект или массив — ререндер будет, если изменилась ссылка на этот объект.

Важно: Старайтесь выбирать из стейта минимально необходимые данные. Если вы выберете весь state.todos, компонент будет перерисовываться при изменении любого поля в этом слайсе, даже если оно не используется в данном компоненте.

Отправка действий: useAppDispatch

Чтобы изменить состояние, мы не можем просто написать state.value = 10 внутри компонента. Мы обязаны отправить «сигнал» или «событие» — Action.

Для этого используется хук useAppDispatch.

import { useAppDispatch } from '../store/hooks';
import { removeTodo } from '../store/features/todos/todosSlice';

// Внутри компонента
const dispatch = useAppDispatch();

// Обработчик клика
const handleDelete = (id: string) => {
  // Мы вызываем генератор экшена removeTodo(id),
  // который возвращает объект { type: 'todos/removeTodo', payload: id }
  // Затем dispatch отправляет этот объект в Store
  dispatch(removeTodo(id));
};

Практика: Реализация фильтрации задач

Давайте усложним наше приложение из прошлого урока. Добавим возможность фильтровать задачи: «Все», «Активные» и «Завершенные».

Шаг 1: Обновление Слайса

Нам нужно где-то хранить текущий выбранный фильтр. Добавим его в todosSlice.ts.

Откройте src/store/features/todos/todosSlice.ts и внесите изменения:

import { createSlice, PayloadAction, nanoid } from '@reduxjs/toolkit';

export interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

// 1. Определяем тип для фильтра
export type TodoFilter = 'all' | 'active' | 'completed';

interface TodosState {
  list: Todo[];
  filter: TodoFilter; // 2. Добавляем поле в стейт
}

const initialState: TodosState = {
  list: [],
  filter: 'all', // 3. Задаем начальное значение
};

export const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    // ... (предыдущие редьюсеры: addTodo, removeTodo, toggleTodo)

    // 4. Добавляем редьюсер для смены фильтра
    setFilter: (state, action: PayloadAction<TodoFilter>) => {
      state.filter = action.payload;
    },
  },
});

export const { addTodo, removeTodo, toggleTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;

Шаг 2: Компонент фильтрации

Создадим небольшой компонент TodoFilters для кнопок переключения. Он будет читать текущий фильтр и менять его.

// src/components/TodoFilters.tsx
import React from 'react';
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { setFilter, TodoFilter } from '../store/features/todos/todosSlice';

export default function TodoFilters() {
  const dispatch = useAppDispatch();
  // Читаем текущий фильтр из Store
  const activeFilter = useAppSelector((state) => state.todos.filter);

  const handleFilterChange = (filter: TodoFilter) => {
    dispatch(setFilter(filter));
  };

  // Вспомогательная функция для стилей
  const getButtonClass = (filter: TodoFilter) =>
    `px-3 py-1 rounded transition-colors ${
      activeFilter === filter
        ? 'bg-blue-500 text-white'
        : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
    }`;

  return (
    <div className="flex justify-center gap-4 mb-6">
      <button
        className={getButtonClass('all')}
        onClick={() => handleFilterChange('all')}
      >
        Все
      </button>
      <button
        className={getButtonClass('active')}
        onClick={() => handleFilterChange('active')}
      >
        Активные
      </button>
      <button
        className={getButtonClass('completed')}
        onClick={() => handleFilterChange('completed')}
      >
        Завершенные
      </button>
    </div>
  );
}

Шаг 3: Вычисляемые данные (Derived Data)

Теперь самое интересное. В компоненте списка нам нужно отображать не все задачи, а только те, которые соответствуют фильтру.

Где производить фильтрацию? В редьюсере? Нет. В компоненте? Да.

Лучшая практика Redux: храните в Store минимальный набор данных, а всё остальное вычисляйте в компонентах (или селекторах) на лету.

Обновим наш основной компонент TodoList.tsx:

import React, { useState } from 'react';
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { addTodo, removeTodo, toggleTodo } from '../store/features/todos/todosSlice';
import TodoFilters from './TodoFilters'; // Импортируем фильтры

export default function TodoList() {
  const [text, setText] = useState('');
  const dispatch = useAppDispatch();

  // 1. Получаем список задач
  const todos = useAppSelector((state) => state.todos.list);
  // 2. Получаем текущий фильтр
  const filter = useAppSelector((state) => state.todos.filter);

  // 3. Вычисляем отфильтрованный список прямо во время рендера
  const filteredTodos = todos.filter((todo) => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });

  const handleAdd = () => {
    if (text.trim()) {
      dispatch(addTodo(text));
      setText('');
    }
  };

  // ... остальной код (handleKeyDown и т.д.)

  return (
    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow-xl">
      <h2 className="text-2xl font-bold mb-4 text-gray-800 text-center">Список задач</h2>

      {/* Вставляем компонент фильтров */}
      <TodoFilters />

      <div className="flex gap-2 mb-6">
         {/* ... инпут и кнопка добавления ... */}
         <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
          className="flex-1 px-4 py-2 border border-gray-300 rounded-lg"
        />
        <button onClick={handleAdd} className="bg-blue-600 text-white px-4 rounded-lg">
           Добавить
        </button>
      </div>

      <ul className="space-y-3">
        {/* Рендерим filteredTodos вместо todos */}
        {filteredTodos.map((todo) => (
          <li key={todo.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
            <div
              className="flex items-center gap-3 cursor-pointer flex-1"
              onClick={() => dispatch(toggleTodo(todo.id))}
            >
               <span className={todo.completed ? 'line-through text-gray-400' : ''}>
                 {todo.text}
               </span>
            </div>
            <button onClick={() => dispatch(removeTodo(todo.id))} className="text-red-500">
              ✕
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Локальный стейт vs Глобальный стейт

Частый вопрос новичков: «Нужно ли мне хранить значение инпута (text) в Redux?»

Ответ: Нет.

Используйте правило:

  • Глобальный стейт (Redux): Данные, которые нужны многим компонентам, или данные приложения (список задач, данные пользователя, настройки темы).
  • Локальный стейт (useState): Данные, которые касаются только текущего компонента UI (значение поля ввода, открыт ли выпадающий список, активная вкладка).

В нашем примере text (текст новой задачи) нужен только компоненту TodoList в момент набора. Как только задача добавлена в Redux, локальный стейт очищается. Это идеальное разделение ответственности.

Резюме

В этой статье мы замкнули круг Redux:

  1. Мы научились читать данные с помощью useAppSelector.
  2. Мы научились изменять данные, отправляя экшены через useAppDispatch.
  3. Мы реализовали логику Derived Data (вычисляемых данных), отфильтровав список задач на основе состояния.

Теперь наше приложение интерактивно. Но что, если данные нужно сохранить на сервере? В следующей статье мы перейдем к одной из самых мощных возможностей Redux Toolkit — асинхронным Thunks и работе с API.

Основы RTK Query: создание API, выполнение запросов и автоматическое кэширование

Основы RTK Query: создание API, выполнение запросов и автоматическое кэширование

В предыдущих статьях мы построили надежный фундамент: настроили Redux Store, типизировали хуки и научились управлять локальным состоянием через слайсы. Однако большинство современных приложений не живут в вакууме. Им нужно общаться с сервером: загружать списки задач, обновлять профили пользователей и отправлять данные форм.

Раньше для этого приходилось писать много шаблонного кода: создавать thunks, вручную отслеживать состояния isLoading и isError, и самое сложное — самостоятельно управлять кэшированием данных.

В этой статье мы познакомимся с RTK Query — мощным инструментом, встроенным в Redux Toolkit, который берет на себя всю грязную работу по выборке и кэшированию данных.

Что такое RTK Query и зачем он нужен?

RTK Query — это надстройка над Redux Toolkit, предназначенная для упрощения работы с API. Она решает следующие задачи:

  1. Отслеживание состояния загрузки: Вам больше не нужно создавать отдельные флаги loading в редьюсерах.
  2. Кэширование: Если данные уже загружены, RTK Query не будет дергать сервер лишний раз.
  3. Дедупликация запросов: Если два компонента одновременно запрашивают одни и те же данные, на сервер уйдет только один запрос.
  4. Автоматическое обновление: Данные в UI обновляются автоматически при изменении кэша.

Шаг 1: Создание API сервиса

В отличие от обычных слайсов, API в RTK Query определяется в одном месте. Мы создадим новый файл для работы с нашим бэкендом (в учебных целях будем использовать JSONPlaceholder или имитировать его).

Создайте файл src/store/services/todosApi.ts.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// Импортируем интерфейс Todo, который мы создали ранее
import { Todo } from '../features/todos/todosSlice';

// Определяем наш API сервис
export const todosApi = createApi({
  // Уникальное имя для slice в Redux Store
  reducerPath: 'todosApi',

  // Базовая конфигурация сети
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com/'
  }),

  // Определение эндпоинтов (точек входа)
  endpoints: (builder) => ({
    // Описываем запрос на получение списка задач
    // <Todo[], void> означает:
    // 1. Сервер вернет массив Todo (Todo[])
    // 2. Мы не передаем никаких аргументов в запрос (void)
    getTodos: builder.query<Todo[], void>({
      query: () => 'todos?_limit=10', // URL будет: base + todos?_limit=10
    }),
  }),
});

// RTK Query автоматически генерирует хуки на основе имен эндпоинтов
// getTodos -> useGetTodosQuery
export const { useGetTodosQuery } = todosApi;

Разбор кода

  • createApi: Основная функция. Она создает слайс и набор мидлваров.
  • fetchBaseQuery: Это легкая обертка над стандартным fetch. Она упрощает настройку заголовков и базового URL.
  • endpoints: Здесь мы перечисляем все операции (получение данных — query, изменение данных — mutation).
  • builder.query<ResultType, QueryArg>: Это TypeScript-дженерик. Он критически важен для типизации. Если вы укажете здесь правильные типы, то в компоненте data будет иметь именно этот тип.

Шаг 2: Подключение к Redux Store

Созданный API нужно зарегистрировать в нашем store. Это делается немного иначе, чем с обычными слайсами.

Откройте файл src/store/store.ts и внесите изменения:

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';
import todosReducer from './features/todos/todosSlice';
import { todosApi } from './services/todosApi'; // 1. Импорт API

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer,
    // 2. Добавляем редьюсер API.
    // Ключ свойства вычисляется динамически: [todosApi.reducerPath]
    [todosApi.reducerPath]: todosApi.reducer,
  },
  // 3. Добавляем middleware API
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(todosApi.middleware),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Зачем нужен Middleware?

Добавление todosApi.middleware обязательно. Именно этот код отвечает за:

  • Управление временем жизни кэша.
  • Опросы сервера (polling).
  • Инвалидацию кэша (автоматическое обновление данных после мутаций).

Шаг 3: Использование хука в компоненте

Теперь самое приятное. Нам не нужно писать useEffect, useState для загрузки и ошибок. Всё это уже есть в сгенерированном хуке useGetTodosQuery.

Давайте создадим новый компонент ServerTodoList.tsx, чтобы не ломать наш старый локальный список, и посмотрим, как это работает.

import React from 'react';
import { useGetTodosQuery } from '../store/services/todosApi';

export default function ServerTodoList() {
  // Хук возвращает объект с данными и статусами
  const {
    data: todos = [], // Получаем данные и задаем значение по умолчанию
    isLoading,        // true, пока идет первая загрузка
    isError,          // true, если произошла ошибка сети
    error             // объект ошибки
  } = useGetTodosQuery();

  if (isLoading) {
    return (
      <div className="flex justify-center p-10">
        <span className="text-blue-500 font-bold text-xl">Загрузка задач...</span>
      </div>
    );
  }

  if (isError) {
    return (
      <div className="text-red-500 text-center p-10">
        Произошла ошибка при загрузке данных.
        <pre className="text-xs mt-2">{JSON.stringify(error, null, 2)}</pre>
      </div>
    );
  }

  return (
    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow-xl">
      <h2 className="text-2xl font-bold mb-4 text-gray-800">Задачи с сервера</h2>
      <ul className="space-y-3">
        {todos.map((todo) => (
          <li
            key={todo.id}
            className="p-3 bg-gray-50 rounded-lg border border-gray-200"
          >
            <span className={todo.completed ? 'line-through text-gray-400' : 'text-gray-700'}>
              {todo.title || todo.text}
              {/* API JSONPlaceholder возвращает поле title, а наш тип использовал text.
                  В реальном проекте типы должны совпадать строго. */}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

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

Магия автоматического кэширования

Одна из главных фишек RTK Query — это умное кэширование. Давайте разберем, как это работает на практике.

Представьте, что у вас есть два компонента на странице, и оба используют useGetTodosQuery().

  1. Первый компонент монтируется. RTK Query видит, что данных в кэше нет. Он отправляет запрос на сервер. Статус меняется на isLoading: true.
  2. Второй компонент монтируется сразу же после первого. RTK Query видит, что запрос на эти данные уже «в полете» (pending). Он не отправляет второй запрос.
  3. Когда данные приходят, оба компонента обновляются одновременно.

Если вы перейдете на другую страницу и вернетесь назад, RTK Query мгновенно отдаст данные из кэша, не показывая спиннер загрузки (если время жизни кэша не истекло).

Управление временем жизни кэша

По умолчанию RTK Query хранит неиспользуемые данные в кэше 60 секунд. Если все компоненты, использующие эти данные, размонтируются, запустится таймер. Если в течение 60 секунд никто снова не запросит эти данные, они будут удалены из памяти.

Вы можете настроить это поведение через параметр keepUnusedDataFor:

getTodos: builder.query<Todo[], void>({
  query: () => 'todos',
  keepUnusedDataFor: 5, // Хранить кэш только 5 секунд
}),

Типизация аргументов запроса

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

// В endpoints:
getTodoById: builder.query<Todo, string>({
  // string - это тип аргумента, который мы передадим в хук
  query: (id) => `todos/${id}`,
}),

Использование:

// В компоненте
const { data: todo } = useGetTodoByIdQuery('1');

Если аргумент изменится (например, вы передадите '2'), RTK Query автоматически сделает новый запрос, так как это уже другой ключ кэша.

Резюме

В этой статье мы сделали огромный шаг вперед, подключив наше приложение к внешнему миру.

Мы узнали:

  1. Как создавать API-сервисы с помощью createApi и fetchBaseQuery.
  2. Как правильно подключать API к Redux Store, не забывая про middleware.
  3. Как использовать автогенерируемые хуки (useGetTodosQuery) для получения данных.
  4. Как работает дедупликация и кэширование запросов.

Теперь наше приложение умеет читать данные с сервера. Но полноценное приложение должно уметь и изменять их. В следующей статье мы разберем Мутации (Mutations) — способ отправлять POST, PUT и DELETE запросы, и научимся автоматически обновлять списки после изменений.

Продвинутый RTK Query: мутации данных, инвалидация тегов и оптимистичные обновления

Продвинутый RTK Query: мутации данных, инвалидация тегов и оптимистичные обновления

Добро пожаловать обратно! В предыдущей статье мы научились «читать» данные с сервера, используя builder.query. Наше приложение теперь умеет отображать список задач, загруженный из API, и даже кэшировать его, чтобы не нагружать сеть лишними запросами.

Но приложение, которое умеет только читать, — это просто цифровая газета. Настоящее веб-приложение должно уметь создавать, обновлять и удалять данные. В терминологии RTK Query эти операции называются Мутациями (Mutations).

Сегодня мы превратим наш список задач в полноценный инструмент управления делами. Мы разберем:

  1. Как создавать и типизировать мутации.
  2. Почему данные не обновляются сами по себе и как это исправить с помощью Тегов.
  3. Как реализовать Оптимистичные обновления (Optimistic Updates), чтобы интерфейс реагировал мгновенно, не дожидаясь ответа сервера.

Анатомия Мутации

В отличие от запросов (query), которые служат для получения данных, мутации (mutation) используются для отправки обновлений на сервер и применения изменений в локальном кэше. Это включает в себя методы HTTP: POST, PUT, PATCH и DELETE.

Вернемся к нашему файлу src/store/services/todosApi.ts и добавим возможность создания новой задачи.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { Todo } from '../features/todos/todosSlice';

export const todosApi = createApi({
  reducerPath: 'todosApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
  endpoints: (builder) => ({
    // Наш старый запрос на получение данных
    getTodos: builder.query<Todo[], void>({
      query: () => 'todos?_limit=10',
    }),

    // НОВАЯ МУТАЦИЯ: Добавление задачи
    addTodo: builder.mutation<Todo, Partial<Todo>>({
      query: (body) => ({
        url: 'todos',
        method: 'POST',
        body,
      }),
    }),

    // НОВАЯ МУТАЦИЯ: Удаление задачи
    deleteTodo: builder.mutation<{ success: boolean; id: string }, string>({
      query: (id) => ({
        url: `todos/${id}`,
        method: 'DELETE',
      }),
    }),
  }),
});

// Хуки генерируются автоматически, но суффикс меняется с Query на Mutation
export const {
  useGetTodosQuery,
  useAddTodoMutation,
  useDeleteTodoMutation
} = todosApi;

Разбор типов

Посмотрите на строку builder.mutation<Todo, Partial<Todo>>:

  1. Первый аргумент (Todo): Тип данных, который вернет сервер после успешной операции.
  2. Второй аргумент (Partial<Todo>): Тип аргумента, который мы передадим при вызове функции. Мы используем Partial, так как при создании задачи у нас еще нет id (его создаст сервер), поэтому мы передаем неполный объект.

Использование мутаций в компоненте

Хук мутации работает иначе, чем хук запроса. useQuery вызывается автоматически при рендере компонента. useMutation возвращает кортеж (массив из двух элементов):

  1. Функция-триггер: Вы вызываете её, когда хотите совершить действие (например, по клику).
  2. Объект состояния: Содержит поля isLoading, isError, data и другие, относящиеся к этому конкретному запросу.

Давайте обновим наш компонент ServerTodoList.tsx:

import React, { useState } from 'react';
import {
  useGetTodosQuery,
  useAddTodoMutation,
  useDeleteTodoMutation
} from '../store/services/todosApi';

export default function ServerTodoList() {
  const { data: todos = [] } = useGetTodosQuery();
  const [newTodoText, setNewTodoText] = useState('');

  // Деструктурируем хуки мутаций
  const [addTodo, { isLoading: isAdding }] = useAddTodoMutation();
  const [deleteTodo] = useDeleteTodoMutation();

  const handleAdd = async () => {
    if (newTodoText.trim()) {
      try {
        // Вызываем функцию-триггер.
        // unwrap() позволяет работать с промисом напрямую и ловить ошибки через catch
        await addTodo({
          title: newTodoText,
          completed: false,
          userId: 1
        }).unwrap();

        setNewTodoText('');
      } catch (error) {
        console.error('Failed to save the todo', error);
      }
    }
  };

  return (
    <div className="p-6">
      <div className="flex gap-2 mb-4">
        <input
          value={newTodoText}
          onChange={(e) => setNewTodoText(e.target.value)}
          disabled={isAdding}
          className="border p-2 rounded"
        />
        <button
          onClick={handleAdd}
          disabled={isAdding}
          className="bg-blue-500 text-white p-2 rounded"
        >
          {isAdding ? 'Добавление...' : 'Добавить'}
        </button>
      </div>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id} className="flex justify-between items-center border-b p-2">
            <span>{todo.title}</span>
            <button
              onClick={() => deleteTodo(todo.id.toString())}
              className="text-red-500"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Проблема кэширования

Если вы запустите этот код, вы заметите странное поведение. Вы нажимаете «Добавить», кнопка показывает «Добавление...», затем возвращается в исходное состояние... но новая задача не появляется в списке!

Почему?

  1. Компонент подписан на getTodos.
  2. getTodos имеет закэшированные данные.
  3. Мы выполнили addTodo, сервер ответил «ОК».
  4. Но RTK Query не знает, что мутация addTodo как-то связана с данными запроса getTodos. Для него это два независимых события.

Нам нужно сообщить RTK Query: «Эй, после того как выполнится добавление задачи, данные списка задач считаются устаревшими. Пожалуйста, загрузи их заново».

Система Тегов (Tags)

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

Настройка тегов

  1. Объявляем типы тегов в createApi.
  2. Предоставляем теги (providesTags) в запросах.
  3. Инвалидируем теги (invalidatesTags) в мутациях.

Обновим todosApi.ts:

export const todosApi = createApi({
  reducerPath: 'todosApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
  // 1. Объявляем возможные теги
  tagTypes: ['Todo'],

  endpoints: (builder) => ({
    getTodos: builder.query<Todo[], void>({
      query: () => 'todos?_limit=10',
      // 2. Сообщаем, что этот запрос предоставляет список 'Todo'
      // Мы используем специальный id 'LIST', чтобы помечать весь список целиком
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Todo' as const, id })),
              { type: 'Todo', id: 'LIST' },
            ]
          : [{ type: 'Todo', id: 'LIST' }],
    }),

    addTodo: builder.mutation<Todo, Partial<Todo>>({
      query: (body) => ({ url: 'todos', method: 'POST', body }),
      // 3. При добавлении мы говорим, что список 'Todo' устарел
      invalidatesTags: [{ type: 'Todo', id: 'LIST' }],
    }),

    deleteTodo: builder.mutation<{ success: boolean; id: string }, string>({
      query: (id) => ({ url: `todos/${id}`, method: 'DELETE' }),
      // При удалении мы инвалидируем конкретную задачу по ID
      invalidatesTags: (result, error, id) => [{ type: 'Todo', id }],
    }),
  }),
});

Как это работает теперь?

  1. getTodos загружает данные и помечает их тегом { type: 'Todo', id: 'LIST' }.
  2. Мы вызываем addTodo.
  3. После успеха addTodo срабатывает invalidatesTags: [{ type: 'Todo', id: 'LIST' }].
  4. RTK Query видит, что тег LIST был инвалидирован.
  5. Он ищет все активные запросы, которые зависят от этого тега (getTodos).
  6. Он автоматически перезапускает getTodos.
  7. Список обновляется, и пользователь видит новую задачу.

Оптимистичные обновления (Optimistic Updates)

Инвалидация тегов — это надежно, но иногда медленно. Пользователь нажимает «Удалить», ждет 0.5 секунды ответа сервера, потом еще 0.5 секунды перезагрузки списка. В современном вебе мы хотим мгновенной реакции.

Оптимистичное обновление — это стратегия, когда мы обновляем интерфейс сразу же после действия пользователя, предполагая, что сервер ответит успехом. Если сервер вернет ошибку, мы откатываем изменения назад.

В RTK Query это делается через onQueryStarted внутри мутации.

Давайте реализуем оптимистичное удаление задачи:

    deleteTodo: builder.mutation<{ success: boolean; id: string }, string>({
      query: (id) => ({ url: `todos/${id}`, method: 'DELETE' }),

      // onQueryStarted вызывается в момент начала мутации
      async onQueryStarted(id, { dispatch, queryFulfilled }) {
        // 1. Оптимистично обновляем кэш getTodos
        const patchResult = dispatch(
          todosApi.util.updateQueryData('getTodos', undefined, (draft) => {
            // draft - это текущее состояние кэша (массив задач)
            // Мы можем мутировать его благодаря Immer
            const index = draft.findIndex((todo) => todo.id.toString() === id);
            if (index !== -1) {
              draft.splice(index, 1);
            }
          })
        );

        try {
          // 2. Ждем ответа от сервера
          await queryFulfilled;
        } catch {
          // 3. Если ошибка — отменяем изменения (rollback)
          patchResult.undo();
        }
      },
    }),

Разбор магии

  1. todosApi.util.updateQueryData: Это утилита для ручного изменения кэша. Она принимает имя эндпоинта (getTodos), аргументы кэша (у нас undefined или void) и функцию-рецепт.
  2. draft: Благодаря Immer мы работаем с черновиком состояния. Мы просто находим индекс и удаляем элемент через splice.
  3. patchResult.undo(): Если queryFulfilled выбросит ошибку (сервер упал), вызов undo() вернет кэш в состояние до нашего вмешательства. Задача «вернется» в список.

Теперь, когда вы нажмете «Удалить», задача исчезнет мгновенно. Пользовательский опыт (UX) становится невероятно плавным.

Резюме

Сегодня мы превратили наше приложение из «читателя» в полноценного «редактора» данных.

Мы изучили:

  • Мутации (builder.mutation): Способ отправлять изменения на сервер.
  • Теги (Tags): Механизм автоматической синхронизации данных. providesTags вешает метки, invalidatesTags срывает их, вызывая перезагрузку.
  • Оптимистичные обновления: Высший пилотаж UX, позволяющий интерфейсу работать быстрее, чем отвечает сервер.

В следующем уроке мы рассмотрим продвинутые техники: обработку ошибок, middleware и трансформацию ответов, чтобы сделать наше приложение пуленепробиваемым.