53. Паттерны Redux с TypeScript
TypeScript: Паттерны Redux для Строгой Типизации
Заголовок раздела «TypeScript: Паттерны Redux для Строгой Типизации»Привет, кодеры! Сегодня мы погрузимся в одну из самых мощных комбинаций в мире фронтенда — Redux, усиленный строгой типизацией TypeScript. Представьте, что Redux — это хорошо организованный склад, где каждое действие — это команда, а состояние — это инвентарь. TypeScript же — это ваш супер-инспектор по качеству, который следит за тем, чтобы каждая команда была понятна, а каждый предмет инвентаря лежал на своём месте и был того типа, который вы ожидаете.
Зачем нам TypeScript с Redux? Всё просто: для предотвращения ошибок, улучшения читаемости кода, облегчения рефакторинга и, конечно, для полного счастья вашего автодополнения в IDE! Мы рассмотрим, как правильно типизировать действия, редюсеры, асинхронные операции с Redux Thunk и как организовать ваш код с помощью паттерна “Ducks”.
🦆 Redux Ducks Паттерн и Базовая Типизация
Заголовок раздела «🦆 Redux Ducks Паттерн и Базовая Типизация»Паттерн “Ducks” предлагает помещать весь код, относящийся к одной фиче Redux (константы действий, редюсеры, создатели действий, и даже селекторы), в один файл. Это делает ваш код более модульным и легкодоступным.
Давайте начнем с создания простого модуля для управления пользователями.
import { AnyAction } from 'redux'; // Используем AnyAction для базовых примеров
// 1. Определение типов действийexport const ADD_USER = 'users/ADD_USER';export const REMOVE_USER = 'users/REMOVE_USER';export const UPDATE_USER = 'users/UPDATE_USER';
// 2. Типы для состояния (State)export interface User { id: string; name: string; email: string;}
export interface UsersState { list: User[]; isLoading: boolean; error: string | null;}
const initialState: UsersState = { list: [], isLoading: false, error: null,};
// 3. Определение типов для каждого действия// Важно использовать `as const` для строковых литералов, чтобы TS мог вывести точный тип.interface AddUserAction { type: typeof ADD_USER; payload: User;}
interface RemoveUserAction { type: typeof REMOVE_USER; payload: { id: string };}
interface UpdateUserAction { type: typeof UPDATE_USER; payload: User;}
// Объединяем все возможные действия в один Union Typeexport type UsersActionTypes = AddUserAction | RemoveUserAction | UpdateUserAction;
// 4. Создатели действий (Action Creators)export const addUser = (user: User): AddUserAction => ({ type: ADD_USER, payload: user,});
export const removeUser = (id: string): RemoveUserAction => ({ type: REMOVE_USER, payload: { id },});
export const updateUser = (user: User): UpdateUserAction => ({ type: UPDATE_USER, payload: user,});
// 5. Редюсер// Редюсер принимает состояние и действие, возвращает новое состояние.// Здесь TypeScript "сужает" тип действия внутри `switch` благодаря discriminated union.export const usersReducer = ( state: UsersState = initialState, action: UsersActionTypes // Теперь action имеет строгий тип): UsersState => { switch (action.type) { case ADD_USER: return { ...state, list: [...state.list, action.payload] }; case REMOVE_USER: return { ...state, list: state.list.filter((user) => user.id !== action.payload.id) }; case UPDATE_USER: return { ...state, list: state.list.map((user) => (user.id === action.payload.id ? action.payload : user)), }; default: // В Redux типично возвращать текущее состояние для неизвестных действий // Если используем AnyAction, то `action` будет иметь тип `never` здесь, // что является хорошим сигналом для проверки всех возможных типов. return state; }};Ключевые моменты:
as constдляtype: Это гарантирует, что TypeScript понимаетADD_USERкак строковый литерал'users/ADD_USER', а не простоstring. Это критически важно для строгой типизации действий и discriminated unions.- Discriminated Unions:
UsersActionTypes— это объединение всех возможных типов действий. TypeScript использует свойствоtype(которое является уникальным для каждого типа действия) для сужения типа внутриswitchоператора. Это позволяет вам безопасно получать доступ кaction.payloadбез приведения типов.
🚀 Асинхронные Действия с Redux Thunk
Заголовок раздела «🚀 Асинхронные Действия с Redux Thunk»Когда дело доходит до асинхронных операций (например, запросов к API), на помощь приходит Redux Thunk. TypeScript позволяет очень элегантно типизировать thunk’и.
Для этого нам понадобятся типы для RootState (общее состояние приложения) и AppDispatch (типизированная функция dispatch).
// store.ts (Файл для настройки вашего Redux-хранилища)import { createStore, combineReducers, applyMiddleware } from 'redux';import { thunk, ThunkAction, ThunkDispatch } from 'redux-thunk'; // Импортируем типы из redux-thunkimport { usersReducer, UsersActionTypes } from './users'; // Импортируем наш редюсер и типы действий
// Корневой редюсер, объединяющий все наши редюсерыconst rootReducer = combineReducers({ users: usersReducer, // ... другие редюсеры});
// 1. Типизируем RootState: тип всего хранилищаexport type RootState = ReturnType<typeof rootReducer>;
// 2. Типизируем Dispatch:// ThunkDispatch - это типизированная версия dispatch, которая может принимать не только Action, но и Thunkexport type AppDispatch = ThunkDispatch<RootState, unknown, UsersActionTypes>; // В третьем параметре перечисляем все возможные действия
// Создаем хранилищеexport const store = createStore(rootReducer, applyMiddleware(thunk));
// Тип для наших Thunk'ов.// Параметры:// R - тип возвращаемого значения Thunk// S - тип состояния (RootState)// E - тип ExtraArgument (если используется с Thunk. В данном случае unknown)// A - тип действия (UsersActionTypes или AnyAction, если у вас много редюсеров)export type AppThunk<ReturnType = void> = ThunkAction< ReturnType, RootState, unknown, UsersActionTypes // Здесь можно использовать AnyAction для всех модулей>;Теперь, используя эти типы, мы можем создать типизированный thunk в нашем users.ts файле.
// users.ts (добавляем к существующему файлу)import { AppThunk, AppDispatch } from './store'; // Импортируем AppThunk и AppDispatch
// Дополнительные типы действий для асинхронных операцийexport const FETCH_USERS_START = 'users/FETCH_USERS_START';export const FETCH_USERS_SUCCESS = 'users/FETCH_USERS_SUCCESS';export const FETCH_USERS_FAILURE = 'users/FETCH_USERS_FAILURE';
interface FetchUsersStartAction { type: typeof FETCH_USERS_START;}
interface FetchUsersSuccessAction { type: typeof FETCH_USERS_SUCCESS; payload: User[];}
interface FetchUsersFailureAction { type: typeof FETCH_USERS_FAILURE; payload: string; // Сообщение об ошибке}
// Расширяем наш Union Type действийexport type UsersActionTypes = | AddUserAction | RemoveUserAction | UpdateUserAction | FetchUsersStartAction | FetchUsersSuccessAction | FetchUsersFailureAction;
// Расширяем редюсер для обработки новых асинхронных действийexport const usersReducer = ( state: UsersState = initialState, action: UsersActionTypes): UsersState => { switch (action.type) { // ... существующие кейсы case FETCH_USERS_START: return { ...state, isLoading: true, error: null }; case FETCH_USERS_SUCCESS: return { ...state, isLoading: false, list: action.payload }; case FETCH_USERS_FAILURE: return { ...state, isLoading: false, error: action.payload }; default: return state; }};
// Типизированный thunk для загрузки пользователейexport const fetchUsers = (): AppThunk<Promise<void>> => { // Thunk получает dispatch и getState, которые уже правильно типизированы! return async (dispatch, getState) => { dispatch({ type: FETCH_USERS_START }); try { // Имитация задержки сети и получения данных const response = await new Promise<User[]>((resolve) => setTimeout(() => { const users: User[] = [ ]; resolve(users); }, 1000) ); dispatch({ type: FETCH_USERS_SUCCESS, payload: response }); } catch (error: any) { dispatch({ type: FETCH_USERS_FAILURE, payload: error.message || 'Failed to fetch users' }); } };};
// Пример использования (в компоненте или другом месте)// import { store } from './store';// store.dispatch(fetchUsers());// store.dispatch(addUser({ id: '3', name: 'Charlie', email: '[email protected]' }));Ключевые моменты:
AppThunk: Определяет универсальный тип для всех thunk’ов, используяThunkActionизredux-thunk. Это дает вам типизацию дляdispatchиgetStateвнутри thunk’а.AppDispatch: Этот тип необходим, если вы хотите вызывать thunk’и черезdispatchв вашем коде (например, в компонентах React сuseDispatch). Он сообщает TypeScript, чтоdispatchможет принимать не только обычные объекты действий, но и thunk’и.
🔍 Типизация Селекторов
Заголовок раздела «🔍 Типизация Селекторов»Селекторы — это функции, которые извлекают части состояния из хранилища. TypeScript делает их невероятно безопасными.
// users.ts (добавляем к существующему файлу)import { RootState } from './store'; // Импортируем RootState
// Базовый селектор: получаем весь пользовательский модуль состоянияexport const selectUsersState = (state: RootState) => state.users;
// Селектор: получаем список пользователейexport const selectAllUsers = (state: RootState) => selectUsersState(state).list;
// Селектор: получаем состояние загрузкиexport const selectIsLoading = (state: RootState) => selectUsersState(state).isLoading;
// Селектор: получаем пользователя по ID (пример с параметром)export const selectUserById = (state: RootState, id: string) => selectUsersState(state).list.find((user) => user.id === id);
// Пример использования// import { store } from './store';// import { selectAllUsers, selectUserById } from './users';// const users = selectAllUsers(store.getState()); // users будет типа User[]// const user = selectUserById(store.getState(), '1'); // user будет типа User | undefinedКлючевые моменты:
RootState: ИспользуяRootStateв качестве параметра селектора, вы получаете полную типизацию состояния, и TypeScript поможет вам при навигации по объекту состояния.
❌ Типичные Ошибки и Решения
Заголовок раздела «❌ Типичные Ошибки и Решения»-
Забыли
as const:- Ошибка:
type: 'users/ADD_USER'вместоtype: typeof ADD_USER. - Проблема: TypeScript будет считать
typeпростоstring, а не строковым литералом. Это сломает discriminated unions и сужение типов в редюсере. - Решение: Всегда используйте
as constдля констант типов действий:export const ADD_USER = 'users/ADD_USER' as const; // Правильно
- Ошибка:
-
Неправильная типизация
dispatchдля Thunk’ов:- Ошибка: Использование
Dispatch<AnyAction>вместоAppDispatchв компонентах или хуках. - Проблема: TypeScript не знает, что
dispatchможет принимать функции (thunk’и), и вы получите ошибку, когда попытаетесь вызватьdispatch(someThunk()). - Решение: Определите
AppDispatchкакThunkDispatch<RootState, unknown, AnyAction>или более строго, как мы сделали, и используйте его:// В хуке React// const dispatch: AppDispatch = useDispatch();// dispatch(fetchUsers()); // Теперь работает!
- Ошибка: Использование
-
Чрезмерно широкий тип действий в редюсере:
- Ошибка:
reducer(state: State, action: AnyAction): State - Проблема: Вы теряете всю пользу от discriminated unions. TypeScript не сможет сузить тип
action.payloadвнутриswitch, и вам придется делать приведения типов ((action as AddUserAction).payload). - Решение: Всегда используйте ваш объединенный тип действий (например,
UsersActionTypes) для конкретного редюсера.
- Ошибка:
🎯 Практика
Заголовок раздела «🎯 Практика»Ваше задание, мой юный падаван Redux-мастер!
-
Создайте новый “Ducks” модуль для управления задачами (Todos):
- Определите интерфейс
Todo(id, title, completed). - Определите
TodosState(список задач, флаги загрузки/ошибки). - Создайте типы действий для:
ADD_TODO,TOGGLE_TODO,DELETE_TODO. Используйтеas const. - Создайте соответствующие интерфейсы для этих действий и объедините их в
TodosActionTypes. - Напишите создателей действий (action creators).
- Напишите
todosReducer.
- Определите интерфейс
-
Добавьте асинхронное действие в модуль Todos:
- Создайте thunk
fetchTodos, который имитирует загрузку списка задач с сервера. - Добавьте соответствующие типы действий (
FETCH_TODOS_START,FETCH_TODOS_SUCCESS,FETCH_TODOS_FAILURE) и обновитеTodosActionTypesиtodosReducer. - Убедитесь, что ваш thunk правильно использует типизированные
dispatchиgetState.
- Создайте thunk
-
Создайте селекторы для модуля Todos:
selectAllTodos: возвращает весь список задач.selectCompletedTodos: возвращает только выполненные задачи.selectTodoById(id: string): возвращает задачу по её ID.
-
Интегрируйте в Root State:
- Обновите
rootReducerвstore.ts, добавив вашtodosReducer. - Обновите
RootStateиAppThunk, чтобы они включали новые типы действий и состояния.
- Обновите
💡 Совет
Заголовок раздела «💡 Совет»Использование Redux Toolkit (RTK) — это современный и рекомендуемый способ работы с Redux. RTK значительно упрощает создание редюсеров, действий и thunk’ов, автоматически генерируя многие boilerplate типы, которые мы создавали вручную. Например, createSlice из RTK сам выводит типы действий и редюсеров, а createAsyncThunk полностью типизирует асинхронные операции.
Хотя важно понимать базовую типизацию Redux, как мы сейчас изучили, для новых проектов настоятельно рекомендую рассмотреть Redux Toolkit — он сделает вашу жизнь гораздо проще и ваш код еще безопаснее!