Мы с вами уже прошли огромный путь, научились создавать слайсы, работать с асинхронными операциями и даже структурировать большие проекты.
Но настал момент сделать наш код по-настоящему профессиональным, надежным и предсказуемым. Мы подошли к тому, чтобы навести в нем строгий порядок с помощью TypeScript. Если вы до сих пор писали на JavaScript, возможно вы уже сталкивались с ошибками вроде undefined is not a function или неправильно переданными пропсами в компоненты. TypeScript это наш главный союзник в борьбе с такими ошибками и сегодня мы научимся применять его силу в связке с Redux Toolkit.
В моем блоге вы также найдете полный цикл уроков по TypeScript для начинающих.
В этом уроке мы сфокусируемся на базовой типизации. Мы не будем углубляться в дебри продвинутых Generic-типов, а сделаем самое главное: заставим наш Store, диспатч и селекторы «понимать» типы данных, с которыми они работают. Это уже на 90% решит большинство потенциальных проблем и невероятно повысит уверенность в вашем коде.
Зачем вообще типизировать Redux?
Redux и так требует написания довольно большого количества шаблонного кода. Зачем же добавлять к этому еще и типы? Ответ прост: предсказуемость и надежность.
Представьте, что вы работаете в команде. Ваш коллега разрабатывает слайс для управления пользователями, а вы компонент, который должен отображать список этих пользователей. Без TypeScript вам придется либо постоянно заглядывать в код слайса, чтобы вспомнить структуру объекта пользователя, либо надеяться на свою память и документацию (которая, как известно, быстро устаревает). Одна опечатка в названии свойства и в лучшем случае вы получите undefined в интерфейсе, а в худшем падение всего приложения.
TypeScript же заставляет вас и ваш редактор кода говорить на одном языке. Он подскажет, что у пользователя есть свойства id, name и email и не даст вам обратиться к несуществующему свойству naame. Когда вы диспатчите экшен, TypeScript проверит, тот ли тип данных вы передаете в payload, который ожидается в редьюсере.
Основные преимущества типизации Redux:
-
Автодополнение и IntelliSense. Ваша IDE (VSCode, WebStorm) будет точно знать, что находится в состоянии и будет предлагать вам доступные свойства.
-
Обнаружение ошибок на этапе компиляции. Вы узнаете об ошибке не в рантайме, когда пользователь уже нажал на кнопку, а сразу при написании кода.
-
Лучшая документация. Типы сами по себе являются отличной документацией, которая всегда актуальна, потому что является частью кода.
-
Безопасный рефакторинг. Если вы решите изменить структуру состояния, TypeScript укажет на все места в коде, которые нужно поправить.
Redux Toolkit изначально написан на TypeScript и предоставляет отличные типы «из коробки». Наша задача правильно их использовать.
Настраиваем типизированный Store
Вся типобезопасность в нашем приложении начинается с правильно типизированного хранилища. Мы должны явно описать его тип, чтобы потом использовать эти описания в хуках useDispatch и useSelector.
Вспомним, как мы создаем store с помощью configureStore:
// store.ts (JavaScript-версия) import { configureStore } from '@reduxjs/toolkit' import counterReducer from './features/counter/counterSlice' export const store = configureStore({ reducer: { counter: counterReducer, }, })
В TypeScript-версии нам нужно сделать немного больше, явно экспортировать типы, которые представляет собой наше состояние store и наш метод dispatch.
// store.ts (TypeScript-версия) import { configureStore } from '@reduxjs/toolkit' import counterReducer from './features/counter/counterSlice' // Создаем store, как и раньше export const store = configureStore({ reducer: { counter: counterReducer, }, }) // Экспортируем центральные типы! // Получаем тип нашего состояния (RootState) export type RootState = ReturnType<typeof store.getState> // Получаем тип нашего Dispatch метода (AppDispatch) export type AppDispatch = typeof store.dispatch
Давайте разберем эти два новых типа:
-
RootState. Этот тип динамически вычисляется на основе того, что возвращает функцияstore.getState(). Мы используем утилиту TypeScriptReturnType, которая извлекает возвращаемый тип функции. Если вы добавите новый слайс вreducer, типRootStateавтоматически расширится и будет включать состояние этого нового слайса. Вам не нужно будет обновлять его вручную. -
AppDispatch. Этот тип просто берется из самого свойстваstore.dispatch. Поскольку мы используем Redux Toolkit, вdispatchуже встроена поддержка thunk-ов и других middleware. Использование этого типа гарантирует, что при диспатче асинхронных операций сcreateAsyncThunkу нас не будет ошибок типизации.
Теперь мы можем использовать их для создания типизированных версий хуков useDispatch и useSelector.
Создаем типизированные хуки useDispatch и useSelector
Хуки useDispatch и useSelector из react-redux это наше окно в мир Redux внутри React-компонентов. По умолчанию они «не знают» о структуре нашего состояния. Мы должны им «рассказать» о ней, используя типы RootState и AppDispatch, которые мы только что создали.
Вместо того чтобы делать это в каждом компоненте (что привело бы к куче повторяющегося кода), мы создадим предварительно типизированные версии этих хуков один раз и будем использовать их по всему приложению.
Создадим для этого отдельный файл, например hooks.ts в папке нашего store.
// store/hooks.ts import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux' import type { RootState, AppDispatch } from './store' // Импортируем наши типы // Создаем типизированную версию useDispatch export const useAppDispatch = () => useDispatch<AppDispatch>() // Создаем типизированную версию useSelector export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
Что здесь происходит?
-
useAppDispatch. Это простая функция, которая вызывает стандартныйuseDispatch, но мы явно указываем, что он работает с нашим типомAppDispatch. Это нужно для корректной работы с thunk-ами. -
useAppSelector. Здесь мы используем специальный типTypedUseSelectorHookизreact-redux, передавая в него нашRootState. Это «обучает» хукuseSelectorпонимать структуру всего нашего состояния.
Теперь во всех наших компонентах мы будем импортировать не стандартные useDispatch и useSelector, а наши кастомные useAppDispatch и useAppSelector. Это даст нам всю мощь TypeScript-подсказок и проверок.
Типизируем слайс счетчика
Давайте вернемся слайсу счетчика и приведем его к TypeScript-виду. Вот как он выглядел на JavaScript:
// counterSlice.js (JavaScript) import { createSlice } from '@reduxjs/toolkit' const initialState = { value: 0, } export const counterSlice = createSlice({ name: 'counter', initialState, reducers: { incremented: (state) => { state.value += 1 }, decremented: (state) => { state.value -= 1 }, incrementedByAmount: (state, action) => { state.value += action.payload }, }, }) export const { incremented, decremented, incrementedByAmount } = counterSlice.actions export default counterSlice.reducer
Теперь напишем его на TypeScript. Нам нужно явно описать тип начального состояния и типы payload для наших экшенов.
// features/counter/counterSlice.ts (TypeScript) import { createSlice, PayloadAction } from '@reduxjs/toolkit' // 1. Определяем тип для нашего состояния interface CounterState { value: number } // 2. Определяем начальное состояние с использованием этого типа const initialState: CounterState = { value: 0, } export const counterSlice = createSlice({ name: 'counter', initialState, // TypeScript автоматически поймет тип здесь reducers: { incremented: (state) => { state.value += 1 }, decremented: (state) => { state.value -= 1 }, // 3. Используем PayloadAction для типизации action.payload incrementedByAmount: (state, action: PayloadAction<number>) => { state.value += action.payload // Теперь action.payload - это гарантированно number }, }, }) // Экспортируем экшены и редьюсер как обычно export const { incremented, decremented, incrementedByAmount } = counterSlice.actions export default counterSlice.reducer
Ключевые изменения:
-
Интерфейс
CounterState. Мы явно объявили, что состояние нашего счетчика это объект с одним полемvalueтипаnumber. -
Типизация
initialState. Мы сказали TypeScript, чтоinitialStateдолжна соответствовать типуCounterState. -
PayloadAction<number>. Это, возможно, самое важное изменение. Мы импортировали типPayloadActionиз Redux Toolkit и использовали его для типизации второго аргумента —action. Указав<number>, мы сообщаем TypeScript, что в свойствеpayloadэтого экшена должно находиться число. Теперь, если мы попытаемся диспатчитьincrementedByAmount('пять'), TypeScript тут же укажет нам на ошибку.
Создаем типизированный компонент счетчика
Давайте соберем все вместе и перепишем наш компонент счетчика, используя новые типизированные хуки и типизированный слайс.
// features/counter/Counter.tsx import React, { useState } from 'react'; // Импортируем НЕ стандартные хуки, а наши типизированные! import { useAppSelector, useAppDispatch } from '../../store/hooks'; // Импортируем наши экшены import { incremented, decremented, incrementedByAmount } from './counterSlice'; export const Counter: React.FC = () => { // 1. Типизированное получение состояния const count = useAppSelector((state) => state.counter.value); const dispatch = useAppDispatch(); // Локальное состояние для инпута (оставляем его, т.к. оно не нужно в глобальном store) const [incrementAmount, setIncrementAmount] = useState<string>('2'); // Тип для инпута - string // 2. Типизированный диспатч экшенов const handleIncrement = () => dispatch(incremented()); const handleDecrement = () => dispatch(decremented()); const handleIncrementByAmount = () => { // Парсим строку в число. TypeScript поможет убедиться, что мы работаем с number. const value = parseInt(incrementAmount, 10) || 0; dispatch(incrementedByAmount(value)); // TypeScript проверит, что value - number! }; return ( <div> <h2>Типизированный Счетчик: {count}</h2> <div> <button onClick={handleIncrement}>+</button> <span>{count}</span> <button onClick={handleDecrement}>-</button> </div> <div> <input value={incrementAmount} onChange={(e) => setIncrementAmount(e.target.value)} type="number" /> <button onClick={handleIncrementByAmount}> Прибавить указанное значение </button> </div> </div> ); };
Что мы получили благодаря TypeScript:
-
В строке
state.counter.valueваш редактор кода будет предоставлять автодополнение. Он точно знает, что послеstate.counterесть только свойствоvalue. -
Если вы по ошибке напишете
state.counter.countилиstate.count.value, TypeScript сразу подчеркнет это красным и скажет, что такого свойства не существует. -
При вызове
dispatch(incrementedByAmount('строка'))TypeScript выдаст ошибку, так как ожидается число.
Практическая задача для закрепления
Чтобы убедиться, что вы поняли материал, выполните следующее задание.
Задача: У вас есть слайс для управления списком дел (todos). Его начальное состояние на JavaScript выглядело так:
const initialState = { items: [ { id: 1, text: 'Изучить Redux', completed: true }, { id: 2, text: 'Изучить TypeScript', completed: false }, ], filter: 'all', // 'all', 'completed', 'active' }
-
Создайте интерфейсы для:
-
Отдельной задачи (
TodoItem), которая должна иметьid(число),text(строка) иcompleted(булево значение). -
Состояния слайса (
TodosState), которое включает массивTodoItemи строкуfilter.
-
-
Типизируйте начальное состояние (
initialState). -
В слайсе есть редьюсер
toggleTodo, который принимаетactionсpayloadв видеidзадачи (число). Типизируйте этот экшен с помощьюPayloadAction. -
Создайте типизированный компонент, который использует
useAppSelectorдля отображения списка задач иuseAppDispatchдля диспатча экшенаtoggleTodo.
Пример решения (типизация слайса):
// features/todos/todosSlice.ts import { createSlice, PayloadAction } from '@reduxjs/toolkit'; // 1. Определяем интерфейсы interface TodoItem { id: number; text: string; completed: boolean; } interface TodosState { items: TodoItem[]; filter: 'all' | 'completed' | 'active'; } // 2. Типизируем начальное состояние const initialState: TodosState = { items: [ { id: 1, text: 'Изучить Redux', completed: true }, { id: 2, text: 'Изучить TypeScript', completed: false }, ], filter: 'all', }; export const todosSlice = createSlice({ name: 'todos', initialState, reducers: { toggleTodo: (state, action: PayloadAction<number>) => { // 3. Типизируем payload const todo = state.items.find(todo => todo.id === action.payload); if (todo) { todo.completed = !todo.completed; } }, // ... другие редьюсеры }, }); export const { toggleTodo } = todosSlice.actions; export default todosSlice.reducer;
Итоги 15-го урока
Вы научились:
-
Создавать типизированный store и экспортировать фундаментальные типы
RootStateиAppDispatch. -
Создавать и использовать типизированные версии хуков
useDispatchиuseSelector. -
Правильно типизировать слайс, его состояние и экшены с помощью
PayloadAction. -
Собирать все это вместе в типобезопасном React-компоненте.
Это база, которая покроет 90% ваших повседневных задач с Redux Toolkit и TypeScript. Вы сразу почувствуете, насколько меньше стало «глупых» ошибок и насколько быстрее вы пишете код благодаря автодополнению.
В следующем, шестнадцатом уроке, мы изучим продвинутую типизацию. Мы поговорим о том, как полностью типизировать createAsyncThunk для безупречной работы с асинхронными запросами и разберем более сложные случаи в слайсах.
Полный курс с уроками по Redux и Redux Toolkit для начинающих: https://max-gabov.ru/redux-dlya-nachinaushih
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


