Я продолжаю наш курс по Redux и Redux Toolkit. Мы с вами уже научились создавать слайсы, работать с асинхронными запросами и даже затронули базовую типизацию. Но сейчас мы подходим к одному из самых основных и сложных рубежей продвинутой типизации.
TypeScript это не просто мода, это инструмент для построения надежных, предсказуемых и легко поддерживаемых приложений. Когда ваше состояние, экшены и редьюсеры полностью типизированы, вы ловите огромное количество ошибок еще на этапе написания кода, а ваш редактор кода превращается в преданного помощника, который подсказывает буквально все.
В этом уроке мы с вами научимся полностью типизировать слайсы, включая все их внутренности, а также разберемся, как правильно работать с типизацией асинхронных операций через createAsyncThunk. Мы превратим наш код из просто работающего в работающий правильно.
В моем блоге вы также найдете полный цикл уроков по TypeScript для начинающих.
Полная типизация слайса
Давайте начнем с того, что уже знакомо, это слайса. Но на этот раз мы вооружимся TypeScript и опишем каждый его элемент так, чтобы никакая опечатка или несоответствие типа не ускользнули от нашего взора.
Типизация initialState
Первое, с чего всегда стоит начинать, это определение типа для начального состояния. Это основа, на которую будут опираться все наши редьюсеры.
Раньше мы просто писали:
const initialState = { items: [], loading: false, error: null, };
Теперь мы должны явно описать, какому типу соответствует этот объект.
Практическая задача: Давайте полностью типизируем слайс для управления списком задач (todos).
// features/todos/todosSlice.ts // 1. Определяем интерфейс для отдельной задачи interface ITodo { id: string; title: string; completed: boolean; createdAt: number; // timestamp } // 2. Определяем интерфейс для всего состояния слайса interface TodosState { items: ITodo[]; loading: 'idle' | 'pending' | 'succeeded' | 'failed'; // Часто используют строковые литералы для статусов error: string | null; } // 3. Создаем начальное состояние с явным указанием типа const initialState: TodosState = { items: [], loading: 'idle', error: null, };
Обрати внимание на несколько ключевых моментов:
-
ITodo. Мы детально описываем, из чего состоит одна задача. Это не просто объект сtext, а структура с ID, статусом выполнения и временем создания. -
TodosState. Мы описываем всю структуру состояния слайса. Массивitemsтеперь строго типизирован, в него можно положить только объекты, соответствующиеITodo. Полеloadingмы сделали не просто булевым, а более информативным, используя объединение типов. Это отличная практика для отслеживания точного статуса запроса. -
initialState: TodosState. Мы явно указываем тип для начального состояния. Это не только хороший тон, но и защита от случайного добавления полей, не описанных в интерфейсе.
Типизация createSlice
Самое интересное начинается при использовании createSlice. Redux Toolkit написан на TypeScript и прекрасно работает с типами, часто выводя их автоматически.
Самое главное, что нам нужно сделать, это типизировать state и action в каждом редьюсере.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; const todosSlice = createSlice({ name: 'todos', initialState, // TypeScript теперь знает, что initialState это TodosState reducers: { // 1. Типизация простого экшена (без payload) clearTodos(state) { // state уже имеет тип TodosState благодаря initialState state.items = []; }, // 2. Тизизация экшена с payload addTodo(state, action: PayloadAction<string>) { // PayloadAction<ТипНашегоПейлоада> - это дженерик тип из RTK // Здесь мы говорим, что в action.payload придет строка (title задачи) const newTodo: ITodo = { id: Date.now().toString(), // Простой способ генерации ID title: action.payload, // TypeScript знает, что это string! completed: false, createdAt: Date.now(), }; state.items.push(newTodo); }, // 3. Типизация экшена с более сложным payload toggleTodo(state, action: PayloadAction<string>) { // В этот раз payload - это ID задачи (строка) const todo = state.items.find(item => item.id === action.payload); if (todo) { todo.completed = !todo.completed; } }, updateTodoTitle(state, action: PayloadAction<{ id: string; newTitle: string }>) { // А здесь payload - это объект с полями id и newTitle const todo = state.items.find(item => item.id === action.payload.id); if (todo) { todo.title = action.payload.newTitle; } }, }, }); // Redux Toolkit сгенерирует типизированные экшены! // Например, тип для addTodo будет: { type: 'todos/addTodo', payload: string } export const { clearTodos, addTodo, toggleTodo, updateTodoTitle } = todosSlice.actions; export default todosSlice.reducer;
Вот что здесь произошло:
-
stateв каждом редьюсере автоматически получил типTodosStateблагодаря тому, что мы передали типизированныйinitialState. -
PayloadAction<T>это наш главный инструмент. Этот дженерик-тип ожидает указания типа дляaction.payload. Без этого TypeScript считал быpayloadтипомanyи мы потеряли бы всю пользу от типизации.
Теперь, когда мы будем диспатчить эти экшены в компонентах, TypeScript будет строго следить за тем, что мы передаем в payload. Попробуешь передать в addTodo число? Получишь ошибку на этапе компиляции.
Типизация createAsyncThunk
Перейдем к более сложной, но не менее важной теме, это типизации асинхронных операций. createAsyncThunk это сердце асинхронности в RTK и его правильная типизация делает работу с API предсказуемой и безопасной.
createAsyncThunk принимает до трех дженерик-аргументов:
-
Returnedтип значения, которое возвращает (резолвится) thunk, если запрос успешен. То, что попадет вaction.payloadв случаеfulfilled. -
ThunkArgтип аргумента, который передается в thunk-функцию при вызове. Например, ID пользователя или объект с данными для создания. -
ThunkApiConfig(опционально) конфигурация для thunk API, например, типы дляstate(getState) иdispatch. Чаще всего нам нужно определить здесь тип дляrejectValue.
Типизируем асинхронный запрос на получение пользователей
Давай представим, что мы хотим загрузить список пользователей с того же JSONPlaceholder.
// features/users/usersSlice.ts import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; // 1. Определяем интерфейс для пользователя interface IUser { id: number; name: string; email: string; username: string; } // 2. Определяем состояние для слайса пользователей interface UsersState { list: IUser[]; loading: 'idle' | 'pending' | 'succeeded' | 'failed'; error: string | null; } const initialState: UsersState = { list: [], loading: 'idle', error: null, }; // 3. Создаем типизированный thunk! // createAsyncThunk<Returned, ThunkArg, { rejectValue: типОшибки }> export const fetchUsers = createAsyncThunk< IUser[], // Returned - успешный запрос вернет массив пользователей void, // ThunkArg - аргументов у нас нет, поэтому void { rejectValue: string } // ThunkApiConfig - мы хотим типизировать rejectValue как строку >('users/fetchUsers', async (_, thunkAPI) => { // _ - это наш ThunkArg (void, поэтому игнорируем) try { const response = await fetch('https://jsonplaceholder.typicode.com/users'); if (!response.ok) { // Если ответ не успешен, выбрасываем ошибку throw new Error('Ошибка при загрузке пользователей'); } const data: IUser[] = await response.json(); return data; // Возвращаем данные -> попадут в action.payload } catch (error) { // Обрабатываем ошибку и возвращаем ее через rejectWithValue return thunkAPI.rejectWithValue((error as Error).message); // Теперь TypeScript знает, что в случае ошибки в action.payload будет строка } });
Разберем по косточкам:
-
IUser[]. Говорим TypeScript, что при успешном запросе мы получим массив объектов типаIUser. -
void. Наш thunk не принимает аргументов, поэтому указываемvoid. -
{ rejectValue: string }. Мы говорим, что в случае отклоненного (rejected) запроса, вaction.payloadбудет находиться строка с сообщением об ошибке. Это достигается с помощью методаthunkAPI.rejectWithValue().
Обработка типизированного thunk в extraReducer
Теперь нам нужно обработать все три состояния нашего thunk’а в слайсе.
const usersSlice = createSlice({ name: 'users', initialState, reducers: { // ... какие-то синхронные редьюсеры ... }, extraReducers: (builder) => { builder // pending - запрос начался .addCase(fetchUsers.pending, (state) => { state.loading = 'pending'; state.error = null; // Чистим старую ошибку }) // fulfilled - запрос успешно завершен .addCase(fetchUsers.fulfilled, (state, action: PayloadAction<IUser[]>) => { // TypeScript знает, что action.payload это IUser[]! state.loading = 'succeeded'; state.list = action.payload; }) // rejected - запрос завершился ошибкой .addCase(fetchUsers.rejected, (state, action) => { state.loading = 'failed'; // Вот здесь ключевой момент типизации ошибок! // action.payload может быть undefined, если ошибка была не обработана через rejectWithValue // Но мы обработали, поэтому используем тип из ThunkApiConfig if (action.payload) { // Ошибка обработана через rejectWithValue state.error = action.payload; } else { // Какая-то другая ошибка (например, сетевая) state.error = action.error.message || 'Неизвестная ошибка'; } }); }, }); export default usersSlice.reducer;
Самое важное здесь, это обработка ошибки в rejected.
-
action.payloadв случаеrejectedимеет типstring | undefined.string, потому что мы так определили в{ rejectValue: string }, аundefinedпотому что, теоретически, ошибка могла произойти не в нашем коде, а где-то еще и тогдаrejectWithValueне был бы вызван. -
Мы делаем простую проверку
if (action.payload)и в зависимости от нее устанавливаем ошибку. Это делает обработку ошибок надежной и типобезопасной.
Сводим все вместе
Задача такая. Расширь слайс задач (todos), добавив асинхронную операцию для сохранения задачи на мнимый сервер. Thunk должен принимать объект задачи (без id, так как сервер его сгенерирует) и возвращать созданную задачу (с id). Не забудь про обработку всех состояний и типизацию ошибок.
Решение:
// features/todos/todosAsyncThunks.ts import { createAsyncThunk } from '@reduxjs/toolkit'; // Описываем, что мы отправляем на сервер (без id) type CreateTodoDto = Omit<ITodo, 'id'>; // Создаем thunk для добавления задачи на "сервер" export const addTodoAsync = createAsyncThunk< ITodo, // Сервер вернет готовую задачу с ID CreateTodoDto, // Мы отправляем объект без ID { rejectValue: string } >('todos/addTodoAsync', async (newTodoData, thunkAPI) => { try { // Имитация запроса к API const response = await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: JSON.stringify({ title: newTodoData.title, body: newTodoData.title, // для примера userId: 1, }), headers: { 'Content-type': 'application/json; charset=UTF-8', }, }); if (!response.ok) { throw new Error('Не удалось создать задачу'); } const data = await response.json(); // Имитируем ответ сервера, создавая объект ITodo // В реальном приложении сервер вернет данные задачи const createdTodo: ITodo = { id: data.id.toString(), // Предположим, сервер вернул ID title: newTodoData.title, completed: false, createdAt: Date.now(), }; return createdTodo; } catch (error) { return thunkAPI.rejectWithValue((error as Error).message); } });
Теперь добавим обработку этого thunk’а в наш слайс todosSlice:
// В файле todosSlice.ts, импортируем addTodoAsync и добавляем extraReducers const todosSlice = createSlice({ name: 'todos', initialState, reducers: { // ... наши старые синхронные редьюсеры (addTodo, toggleTodo и т.д.) ... }, extraReducers: (builder) => { builder .addCase(addTodoAsync.pending, (state) => { // Можно добавить индикатор загрузки для добавления, если нужно // state.loading = 'pending'; // Но осторожно, это может конфликтовать с другими операциями! }) .addCase(addTodoAsync.fulfilled, (state, action: PayloadAction<ITodo>) => { // Сервер вернул созданную задачу -> добавляем ее в состояние state.items.push(action.payload); }) .addCase(addTodoAsync.rejected, (state, action) => { // Обрабатываем ошибку, например, показываем уведомление console.error('Ошибка при добавлении задачи:', action.payload); // Или можно записать ошибку в отдельное поле состояния // state.addTodoError = action.payload; }); // .addCase(...) // здесь же можно обрабатывать другие thunk'и }, });
Итоги и выводы 16-го урока
Мы с прошли один из самых сложных, но и самых полезных уроков. Давай резюмируем, что мы сегодня сделали:
-
Типизировали слайс полностью. Мы начали с интерфейсов для
initialStateи пошагово типизировали каждый редьюсер, используяPayloadAction<T>. Это гарантирует, что данные в нашем состоянии всегда соответствуют ожидаемой структуре. -
Познакомились с
createAsyncThunkдженериками. Мы разобрали три ключевых аргумента типизации:Returned,ThunkArgиThunkApiConfig(в частности,rejectValue). -
Научились обрабатывать ошибки типобезопасно. Использование
rejectWithValueи последующая проверкаaction.payloadвrejectedкейсе. Это профессиональный подход к обработке асинхронных ошибок в Redux. -
Выполнили практическое задание. Мы создали полноценный типизированный асинхронный thunk для добавления задачи и интегрировали его в слайс.
Теперь код стал не просто мощным благодаря Redux Toolkit, но и невероятно надежным благодаря TypeScript. Сочетание предсказуемости Flux-архитектуры и статической типизации, это тот фундамент, на котором строятся современные, масштабируемые фронтенд-приложения.
Типизация это навык, который оттачивается практикой. Возвращайся к этому уроку, перечитывай код, пробуй типизировать свои собственные слайсы и thunk’и. Вскоре это станет твоей второй натурой.
В следующем уроке мы посмотрим, как тестировать React-компоненты, которые используют все это богатство, Redux, RTK Query и TypeScript.
Полный курс с уроками по Redux и Redux Toolkit для начинающих
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


