Перейти к содержимому

51. TypeScript с Redux: основы

Привет, кодер! Сегодня мы погрузимся в одну из самых мощных комбинаций в мире фронтенда: Redux и TypeScript. Если Redux — это мозг вашего приложения, управляющий центральной нервной системой (состоянием), то TypeScript — это личный нейрохирург, который гарантирует, что каждый сигнал, каждый нейрон и каждый процесс работают безупречно, без неожиданных сбоев.

Вы уже знакомы с Redux и его основными принципами: хранилище, редюсеры, действия. Но когда проект начинает расти, и ваше состояние становится похожим на сложный лабиринт Минотавра, именно TypeScript становится тем Ариадниным клубком, который не даст вам заблудиться, обеспечивая строгую типизацию на каждом шагу. Погнали!

Без TypeScript Redux-приложения часто сталкиваются с проблемами типа “ой, я ожидал строку, а получил число” или “почему у этого объекта нет свойства userId?”. В больших командах или долгоживущих проектах это может привести к кошмару отладки. TypeScript решает эти проблемы, позволяя:

  1. Объявлять четкую структуру состояния (State): Больше никаких догадок о том, что хранится в store.user.profile.
  2. Типизировать действия (Actions): Гарантировать, что каждое действие имеет ожидаемый type и корректную payload.
  3. Безопасно работать с редюсерами (Reducers): Компилятор проследит, чтобы редюсеры всегда принимали и возвращали состояние правильного типа, и корректно обрабатывали типизированные действия.
  4. Улучшать автодополнение и рефакторинг: IDE становится вашим лучшим другом, подсказывая доступные поля и методы, а изменение типа в одном месте безопасно отражается по всему приложению.

В общем, TypeScript превращает Redux из мощного, но потенциально опасного инструмента в предсказуемую, безопасную и невероятно эффективную систему управления состоянием.

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

src/store/types.ts
export interface UserProfile {
id: string;
username: string;
email: string;
isAuthenticated: boolean;
preferences: {
theme: 'dark' | 'light';
notificationsEnabled: boolean;
};
}
export interface Product {
id: string;
name: string;
price: number;
currency: string;
quantity: number;
}
export interface AppState {
user: UserProfile;
products: Product[];
isLoading: boolean;
error: string | null;
}
// Этот тип будет представлять весь глобальный стейт приложения.
// Мы получим его позже из самого стора.
// export type RootState = ReturnType<typeof rootReducer>;

Действия — это команды, которые говорят Redux, что произошло. Крайне важно, чтобы каждая команда была четкой и имела строго определенные параметры. Здесь мы часто используем объединяющие (union) типы для всех возможных действий.

src/store/user/actions.ts
// Определяем базовый интерфейс для всех действий Redux
interface Action<T extends string> {
type: T;
}
// Интерфейс для действия входа пользователя
export interface LoginAction extends Action<'user/login'> {
payload: {
username: string;
token: string;
};
}
// Интерфейс для действия выхода пользователя
export interface LogoutAction extends Action<'user/logout'> {}
// Интерфейс для действия обновления профиля
export interface UpdateProfileAction extends Action<'user/updateProfile'> {
payload: Partial<{
username: string;
email: string;
theme: 'dark' | 'light';
}>;
}
// Объединяем все действия пользователя в один тип
export type UserAction = LoginAction | LogoutAction | UpdateProfileAction;
// Пример action creator
export const login = (username: string, token: string): LoginAction => ({
type: 'user/login',
payload: { username, token },
});
export const logout = (): LogoutAction => ({
type: 'user/logout',
});
export const updateProfile = (
updates: Partial<{ username: string; email: string; theme: 'dark' | 'light' }>
): UpdateProfileAction => ({
type: 'user/updateProfile',
payload: updates,
});

Обратите внимание на Action<'user/login'>. Это позволяет TypeScript понимать, что type всегда будет строковым литералом 'user/login', а не просто string.

Редюсеры — это чистые функции, которые принимают текущее состояние и действие, а затем возвращают новое состояние. Здесь TypeScript следит за тем, чтобы входные и выходные типы соответствовали AppState и чтобы логика обработки действий была безопасной.

src/store/user/reducer.ts
import { UserProfile } from '../types';
import { UserAction } from './actions'; // Импортируем наш тип действий
// Начальное состояние для пользователя
const initialUserState: UserProfile = {
id: 'guest',
username: 'Гость',
email: '',
isAuthenticated: false,
preferences: {
theme: 'light',
notificationsEnabled: true,
},
};
// Типизируем параметры и возвращаемое значение редюсера
export const userReducer = (
state: UserProfile = initialUserState, // Указываем тип для состояния и начальное значение
action: UserAction // Указываем тип для действия
): UserProfile => {
switch (action.type) {
case 'user/login':
// TypeScript знает, что action здесь типа LoginAction,
// поэтому payload.username и payload.token доступны
return {
...state,
id: action.payload.token.split('-')[0], // Просто пример id из токена
username: action.payload.username,
isAuthenticated: true,
};
case 'user/logout':
// TypeScript знает, что action здесь типа LogoutAction
return {
...initialUserState, // Сбрасываем до начального состояния
id: 'guest', // Убедимся, что ID тоже сброшен
};
case 'user/updateProfile':
// TypeScript знает, что action здесь типа UpdateProfileAction
return {
...state,
username: action.payload.username || state.username,
email: action.payload.email || state.email,
preferences: {
...state.preferences,
theme: action.payload.theme || state.preferences.theme,
},
};
default:
// Всегда возвращаем текущее состояние по умолчанию
return state;
}
};

И, наконец, сам Redux Store. Нам нужны типы для всего глобального состояния (RootState) и для функции dispatch. Это критически важно для работы с хуками useSelector и useDispatch в React.

Обычно мы получаем эти типы из самого сконфигурированного хранилища.

src/store/index.ts
import {
combineReducers,
configureStore,
ThunkAction,
Action,
} from '@reduxjs/toolkit'; // Импортируем из Redux Toolkit
import { userReducer } from './user/reducer';
import { AppState } from './types'; // Импортируем наш тип AppState
// Объединяем все редюсеры
const rootReducer = combineReducers<AppState>({
user: userReducer,
// ...другие редюсеры
// products: productsReducer,
// isLoading: loadingReducer,
// error: errorReducer,
});
export const store = configureStore({
reducer: rootReducer,
// middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(myLogger),
// devTools: process.env.NODE_ENV !== 'production',
});
// Тип для всего состояния приложения, выведенный из rootReducer
export type RootState = ReturnType<typeof store.getState>;
// Тип для функции dispatch, включая Thunks
export type AppDispatch = typeof store.dispatch;
// Тип для Thunk'ов, если вы их используете
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown, // Extra arguments for thunks
Action<string>
>;
// src/hooks.ts (для удобства использования в React-компонентах)
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
// Импортируем наши типизированные AppDispatch и RootState
import type { RootState, AppDispatch } from './store';
// Используем TypedUseSelectorHook и useDispatch для создания типизированных версий хуков
// Это позволит вам получить корректную типизацию состояния в useSelector
export const useAppDispatch: () => AppDispatch = useDispatch;
// Это позволит вам получить корректную типизацию частей состояния из useSelector
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Теперь, когда вы используете useAppSelector в компоненте, вы получите полное автодополнение и проверку типов для всего вашего Redux-состояния!

src/components/UserProfileDisplay.tsx
import React from 'react';
import { useAppSelector } from '../hooks'; // Импортируем наш типизированный хук
const UserProfileDisplay: React.FC = () => {
// Теперь TypeScript знает структуру userProfile
const userProfile = useAppSelector((state) => state.user);
const theme = useAppSelector((state) => state.user.preferences.theme);
return (
<div>
<h2>Профиль пользователя</h2>
<p>Имя пользователя: {userProfile.username}</p>
<p>Email: {userProfile.email}</p>
<p>Аутентифицирован: {userProfile.isAuthenticated ? 'Да' : 'Нет'}</p>
<p>Тема: {theme}</p>
</div>
);
};
export default UserProfileDisplay;

Если вы еще не используете Redux Toolkit, самое время начать! RTK значительно упрощает работу с Redux, особенно в плане типизации, так как он максимально использует вывод типов TypeScript. Большинство бойлерплейта, который мы писали выше, RTK генерирует сам.