52. Redux Toolkit (RTK) + TypeScript
TypeScript: Redux Toolkit (RTK) + TypeScript
Заголовок раздела «TypeScript: Redux Toolkit (RTK) + TypeScript»Здарова, кодеры! Яша снова на связи. Сегодня мы погрузимся в мир, где управление состоянием приложения становится не просто логичным, но и удивительно приятным благодаря TypeScript. Если вы уже работали с Redux, то наверняка знаете, сколько boilerplate-кода он может породить. Redux Toolkit (RTK) — это ваш швейцарский нож, который не только решает эту проблему, но и делает работу с Redux в TypeScript невероятно интуитивной и безопасной.
Схема Redux Flow
Заголовок раздела «Схема Redux Flow»flowchart LR UI[UI Component] -->|dispatch action| Store[(Redux Store)] Store -->|run| Reducer[Reducer] Reducer -->|update| State[State] State -->|subscribe| UI
Middleware{{Middleware}} -.->|intercept| Store Thunk[Async Thunk] -.->|dispatch| Store
style Store fill:#764abc,color:#fff style Reducer fill:#61dafb style State fill:#ffd700 style UI fill:#e1f5ffПредставьте, что у вас есть огромная фабрика по производству данных (ваш Redux store). Раньше, чтобы создать новый продукт (часть состояния) или изменить старый, вам приходилось писать кучу инструкций: отдельные цеха для каждого этапа (action types), куча рабочих, которые просто перекладывают бумаги (action creators), и целые отделы, которые определяют, что делать с этими бумагами (reducers). Это работало, но было медленно и утомительно.
Redux Toolkit — это модернизация этой фабрики. Теперь у вас есть автоматизированные линии сборки (слайсы), которые сами генерируют инструкции и управляют рабочими. А TypeScript? TypeScript — это ваш отдел контроля качества, который следит, чтобы каждая деталь была нужного типа, каждый процесс был описан, и на выходе вы всегда получали предсказуемый и безошибочный продукт. Вместе RTK и TypeScript превращают рутину в элегантный танец кода.
🛠️ Основы Redux Toolkit: Слайсы и Стор
Заголовок раздела «🛠️ Основы Redux Toolkit: Слайсы и Стор»В основе RTK лежит концепция “слайсов” (slices). Слайс — это единый объект, который объединяет в себе начальное состояние, редьюсеры и экшены для конкретной части вашего состояния. Это как мини-модуль для управления одной сущностью (например, пользователем, списком постов, счетчиком).
Создание Слайса
Заголовок раздела «Создание Слайса»Давайте создадим слайс для управления счетчиком, используя createSlice.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// 1. Определяем тип нашего состоянияinterface CounterState { value: number; status: 'idle' | 'loading' | 'failed';}
// 2. Определяем начальное состояниеconst initialState: CounterState = { value: 0, status: 'idle',};
// 3. Создаем слайс с помощью createSliceexport const counterSlice = createSlice({ name: 'counter', // Имя слайса, используется как префикс для action types initialState, // Начальное состояние reducers: { // Reducer для инкремента. RTK автоматически генерирует action creator 'counter/increment'. increment: (state) => { // Здесь мы можем "мутировать" состояние напрямую, // Immer.js под капотом гарантирует иммутабельность. state.value += 1; }, // Reducer для декремента. decrement: (state) => { state.value -= 1; }, // Reducer с payload. PayloadAction<T> типизирует полезную нагрузку экшена. incrementByAmount: (state, action: PayloadAction<number>) => { state.value += action.payload; }, // Асинхронный "заглушка" для демонстрации изменения статуса setLoading: (state, action: PayloadAction<boolean>) => { state.status = action.payload ? 'loading' : 'idle'; } },});
// 4. Экспортируем action creators, сгенерированные createSliceexport const { increment, decrement, incrementByAmount, setLoading } = counterSlice.actions;
// 5. Экспортируем редьюсер для использования в storeexport default counterSlice.reducer;Настройка Сторон (Store)
Заголовок раздела «Настройка Сторон (Store)»Теперь, когда у нас есть слайс, его нужно добавить в наш Redux Store. Для этого используем configureStore. Он автоматически настроит DevTools, thunk middleware и другие полезные вещи.
import { configureStore } from '@reduxjs/toolkit';import counterReducer from '../features/counter/counterSlice'; // Импортируем редьюсер нашего слайса
export const store = configureStore({ reducer: { counter: counterReducer, // Добавляем наш слайс в стор // Здесь могут быть и другие редьюсеры от других слайсов },});
// 1. Выводим типы RootState и AppDispatch из нашего store.// Это критически важно для типизации хуков useSelector и useDispatch.export type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;RootState — это тип всего нашего состояния, а AppDispatch — тип функции dispatch, которая знает о всех возможных экшенах и thunk’ах.
☁️ Асинхронные Операции с createAsyncThunk
Заголовок раздела «☁️ Асинхронные Операции с createAsyncThunk»Что если нам нужно выполнить асинхронную операцию, например, получить данные с сервера? Redux Toolkit предоставляет createAsyncThunk для элегантной работы с асинхронностью, полностью типизированной.
Представьте, что мы хотим получить список пользователей.
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
// 1. Определяем тип для пользователяinterface User { id: number; name: string; email: string;}
// 2. Определяем тип состояния для пользователейinterface UsersState { users: User[]; status: 'idle' | 'loading' | 'failed' | 'succeeded'; error: string | null;}
const initialState: UsersState = { users: [], status: 'idle', error: null,};
// 3. Создаем асинхронный thunk для получения пользователей// Типы: <Возвращаемый тип, Тип аргумента для thunk, Конфигурация для thunk API>export const fetchUsers = createAsyncThunk< User[], // Возвращаемый тип: массив User void, // Тип аргумента: void, т.к. не принимаем аргументов { rejectValue: string } // Тип для rejectValue в случае ошибки>( 'users/fetchUsers', // Уникальный префикс для экшенов thunk'а async (_, { rejectWithValue }) => { try { const response = await fetch('https://jsonplaceholder.typicode.com/users'); if (!response.ok) { throw new Error('Failed to fetch users'); } const data = await response.json(); return data as User[]; // Приводим к нашему типу } catch (error: any) { return rejectWithValue(error.message); // Возвращаем ошибку через rejectWithValue } });
export const usersSlice = createSlice({ name: 'users', initialState, reducers: { // Здесь могут быть синхронные редьюсеры }, // extraReducers обрабатывает экшены, сгенерированные НЕ этим слайсом. // В нашем случае, это экшены от createAsyncThunk. extraReducers: (builder) => { builder .addCase(fetchUsers.pending, (state) => { state.status = 'loading'; state.error = null; }) .addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => { state.status = 'succeeded'; state.users = action.payload; }) .addCase(fetchUsers.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload as string || 'An unknown error occurred'; }); },});
export default usersSlice.reducer;Не забудьте добавить usersSlice.reducer в store.ts!
📡 RTK Query: Упрощаем Запросы к API
Заголовок раздела «📡 RTK Query: Упрощаем Запросы к API»Для более сложных сценариев работы с API, кэшированием, инвалидацией и автоматическим формированием хуков, RTK предлагает RTK Query. Это мощный инструмент, который избавляет от написания множества thunk’ов и редьюсеров для управления состоянием запросов.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// Определяем базовый интерфейс для постаinterface Post { userId: number; id: number; title: string; body: string;}
// Определяем наш API сервисexport const jsonplaceholderApi = createApi({ reducerPath: 'jsonplaceholderApi', // Уникальное имя для редьюсера API baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }), // Базовый URL для всех запросов endpoints: (builder) => ({ // Endpoint для получения всех постов getPosts: builder.query<Post[], void>({ // <Тип возвращаемых данных, Тип аргумента> query: () => 'posts', // Путь относительно baseUrl }), // Endpoint для получения одного поста по ID getPostById: builder.query<Post, number>({ query: (id) => `posts/${id}`, }), // Endpoint для создания нового поста (мутация) createPost: builder.mutation<Post, Omit<Post, 'id' | 'userId'>>({ // Omit - для игнорирования полей query: (newPost) => ({ url: 'posts', method: 'POST', body: newPost, }), }), }),});
// RTK Query автоматически генерирует React-хуки для каждого эндпоинтаexport const { useGetPostsQuery, useGetPostByIdQuery, useCreatePostMutation } = jsonplaceholderApi;Добавьте редьюсер jsonplaceholderApi.reducer в store.ts и jsonplaceholderApi.middleware в middleware вашего стора:
// src/app/store.ts (обновленный)import { configureStore } from '@reduxjs/toolkit';import counterReducer from '../features/counter/counterSlice';import usersReducer from '../features/users/usersSlice';import { jsonplaceholderApi } from '../services/jsonplaceholderApi'; // Импорт API сервиса
export const store = configureStore({ reducer: { counter: counterReducer, users: usersReducer, [jsonplaceholderApi.reducerPath]: jsonplaceholderApi.reducer, // Добавляем редьюсер RTK Query }, // Добавляем middleware RTK Query для кэширования, инвалидации и т.д. middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(jsonplaceholderApi.middleware),});
export type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;🔗 Типизируем Хуки: useSelector и useDispatch
Заголовок раздела «🔗 Типизируем Хуки: useSelector и useDispatch»Чтобы избежать постоянного приведения типов при использовании useSelector и useDispatch в ваших компонентах, создайте типизированные версии этих хуков.
См. также: React Hooks | useCallback | useMemo
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';import type { RootState, AppDispatch } from './store';
// Используйте эти типизированные хуки по всему приложению вместо обычных useDispatch и useSelectorexport const useAppDispatch: () => AppDispatch = useDispatch;export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;Теперь в ваших React-компонентах вы можете использовать useAppDispatch и useAppSelector без проблем с типизацией:
// Пример использования в React компонентеimport React from 'react';import { useAppSelector, useAppDispatch } from '../../app/hooks';import { increment, decrement, incrementByAmount } from './counterSlice';import { fetchUsers } from '../users/usersSlice'; // Предполагаем, что юзеры тоже тут
function CounterComponent() { const count = useAppSelector((state) => state.counter.value); const counterStatus = useAppSelector((state) => state.counter.status); const users = useAppSelector((state) => state.users.users); const usersStatus = useAppSelector((state) => state.users.status); const dispatch = useAppDispatch();
// Использование RTK Query // const { data: posts, error, isLoading } = useGetPostsQuery();
return ( <div> <p>Счетчик: {count} (Статус: {counterStatus})</p> <button onClick={() => dispatch(increment())}>Инкремент</button> <button onClick={() => dispatch(decrement())}>Декремент</button> <button onClick={() => dispatch(incrementByAmount(5))}>Добавить 5</button>
<p>Пользователи: {usersStatus}</p> <button onClick={() => dispatch(fetchUsers())}>Загрузить пользователей</button> {usersStatus === 'loading' && <p>Загрузка...</p>} {usersStatus === 'succeeded' && ( <ul> {users.map((user) => ( <li key={user.id}>{user.name} ({user.email})</li> ))} </ul> )} {/* ... отображение постов из RTK Query ... */} </div> );}
export default CounterComponent;🐛 Типичные Ошибки и Как Их Избежать
Заголовок раздела «🐛 Типичные Ошибки и Как Их Избежать»- Забыли экспортировать
RootStateиAppDispatch: Без нихuseSelectorиuseDispatchне будут знать типы вашего стора, и вам придется вручную приводить типы везде. Всегда экспортируйте их изstore.tsи используйте для создания типизированных хуков. - Неправильная типизация
PayloadAction: Если вы передаете полезную нагрузку в редьюсер, всегда явно указывайте типPayloadAction<YourType>. Это поможет TypeScript проверить, что вы отправляете правильные данные. - Неверная типизация
createAsyncThunk: Самая частая ошибка. Помните о порядке генерик-параметров:<ВозвращаемыйТип, ТипАргумента, ТипКонфигурацииТанка>. Особенно важенТипКонфигурацииТанкадляrejectValue. - Мутации вне Redux Toolkit: Помните, что “мутировать” состояние напрямую (например,
state.value += 1) безопасно только внутриcreateSliceблагодаря Immer.js. В других местах (например, в компонентах) всегда создавайте новые объекты или массивы. - Игнорирование ошибок в
extraReducers: Всегда обрабатывайтеrejectedсостояние thunk’ов. Это поможет вам отлавливать и отображать ошибки пользователю.
🎯 Практика
Заголовок раздела «🎯 Практика»Время закрепить материал! Создайте следующие компоненты и Redux логику:
- Расширение
counterSlice: Добавьте вcounterSliceновыйreducer:reset: сбрасываетvalueдо 0.setCustomValue: принимаетPayloadAction<number | string>и устанавливаетvalue. Если приходит строка, попытайтесь преобразовать ее в число; если это невозможно, установитеvalueв 0 и выведите предупреждение в консоль (или в полеerrorсостояния). Убедитесь, чтоCounterStateможет хранитьerror(типstring | null).
todosSliceс CRUD-операциями и асинхронностью:- Создайте новый слайс
todosSliceдля управления списком задач. - Определите интерфейс
Todo:{ id: string; text: string; completed: boolean; }. - Добавьте синхронные редьюсеры:
addTodo: принимаетPayloadAction<string>(текст задачи) и добавляет новую задачу с уникальнымid,completed: false.toggleTodo: принимаетPayloadAction<string>(id задачи) и переключаетcompletedу соответствующей задачи.removeTodo: принимаетPayloadAction<string>(id задачи) и удаляет задачу.
- Добавьте асинхронный thunk
fetchTodosс помощьюcreateAsyncThunk, который будет загружать начальный список задач из фиктивного API (например,fetch('https://jsonplaceholder.typicode.com/todos?_limit=5')). Убедитесь, что статус загрузки (pending, fulfilled, rejected) правильно отображается в состоянииtodosSlice.
- Создайте новый слайс
- Использование RTK Query для постов:
- Используйте
jsonplaceholderApi(или создайте свой дляjson-server) для отображения списка постов. - В React-компоненте используйте
useGetPostsQueryдля получения всех постов иuseGetPostByIdQueryдля отображения деталей одного поста при клике на него. - Добавьте кнопку “Создать пост”, которая будет использовать
useCreatePostMutationдля отправки нового поста на сервер.
- Используйте
💡 Совет
Заголовок раздела «💡 Совет»Используйте возможности TypeScript по максимуму! Чем точнее вы опишете свои интерфейсы, PayloadAction и параметры createAsyncThunk, тем меньше ошибок вы совершите и тем легче будет поддерживать ваш код. Redux Toolkit + TypeScript — это мощнейшая комбинация, которая не только упрощает разработку, но и делает её гораздо более надёжной и предсказуемой. Не бойтесь экспериментировать с RTK Query — он значительно сокращает количество boilerplate-кода для работы с API!