Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
Использование с TypeScript
- Стандартные подходы к настройке Redux-приложения с TypeScript
- Методы корректной типизации частей Redux-логики
- Понимание синтаксиса и терминологии TypeScript
- Знакомство с концепциями TypeScript: дженерики и утилитарные типы
- Знание React Hooks
Обзор
TypeScript — это типизированное надмножество JavaScript, обеспечивающее проверку исходного кода на этапе компиляции. При использовании с Redux TypeScript помогает обеспечить:
-
Типобезопасность для редюсеров, состояния, создателей действий и UI-компонентов
-
Упрощённый рефакторинг типизированного кода
-
Улучшенный опыт разработки в командной среде
Мы настоятельно рекомендуем использовать TypeScript в Redux-приложениях. Однако, как и все инструменты, TypeScript имеет компромиссы. Он добавляет сложность в виде написания дополнительного кода, понимания синтаксиса TS и сборки приложения. В то же время он приносит пользу, выявляя ошибки раньше на этапе разработки, обеспечивая безопасный и эффективный рефакторинг, а также выступая документацией для существующего кода.
Мы считаем, что прагматичное использование TypeScript предоставляет достаточно преимуществ для оправдания дополнительных усилий, особенно в крупных проектах, но вам следует оценить компромиссы и решить, стоит ли использовать TS в вашем приложении.
Существует несколько подходов к типизации Redux-кода. На этой странице представлены наши стандартные рекомендуемые практики совместного использования Redux и TypeScript, но это не исчерпывающее руководство. Следование этим практикам обеспечит хороший опыт использования TS с оптимальным балансом между типобезопасностью и объёмом добавляемых объявлений типов.
Стандартная настройка проекта Redux Toolkit с TypeScript
Мы предполагаем, что типичный Redux-проект использует совместно Redux Toolkit и React Redux.
Redux Toolkit (RTK) — стандартный подход для написания современной Redux-логики. RTK изначально написан на TypeScript, а его API разработан для удобной работы с TS.
Типы для React Redux находятся в отдельном пакете @types/react-redux на NPM. Помимо типизации функций библиотеки, типы также предоставляют хелперы для удобного создания типобезопасных интерфейсов между хранилищем Redux и React-компонентами.
Начиная с React Redux v7.2.3, пакет react-redux зависит от @types/react-redux, поэтому типы устанавливаются автоматически. В противном случае их нужно установить вручную (обычно npm install @types/react-redux).
Шаблон Redux+TS для Create-React-App содержит рабочий пример этих паттернов с предварительной настройкой.
Определение типов Root State и Dispatch
Использование configureStore не требует дополнительных объявлений типов. Однако вам потребуется извлечь типы RootState и Dispatch для последующего использования. Вывод этих типов из самого хранилища гарантирует их актуальность при добавлении срезов состояния или изменении настроек middleware.
Поскольку это типы, их можно безопасно экспортировать напрямую из файла настройки хранилища (например, app/store.ts) и импортировать в другие файлы.
import { configureStore } from '@reduxjs/toolkit'
// ...
export const store = configureStore({
reducer: {
posts: postsReducer,
comments: commentsReducer,
users: usersReducer
}
})
// Get the type of our store variable
export type AppStore = typeof store
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore['getState']>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = AppStore['dispatch']
Создание типизированных хуков
Хотя можно импортировать типы RootState и AppDispatch в каждый компонент, лучше создать предварительно типизированные версии хуков useDispatch и useSelector для использования в приложении. Это важно по нескольким причинам:
-
Для
useSelectorэто избавляет от необходимости каждый раз указывать(state: RootState) -
Для
useDispatchстандартный типDispatchне учитывает thunk-и или другое middleware. Чтобы корректно диспатчить thunk-и, необходимо использовать кастомный типAppDispatchиз хранилища, который включает типы thunk-middleware, и применять его сuseDispatch. Предварительно типизированный хукuseDispatchпредотвращает забывание импортаAppDispatchтам, где это требуется.
Поскольку это переменные, а не типы, их важно определять в отдельном файле (например, app/hooks.ts), а не в файле настройки хранилища. Это позволяет импортировать их в любые компоненты и избежать потенциальных проблем циклических зависимостей при импорте.
.withTypes()
Ранее подход к "предварительной типизации" хуков в настройках приложения варьировался. Результат выглядел примерно так, как в сниппете ниже:
import type { TypedUseSelectorHook } from 'react-redux'
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { AppDispatch, AppStore, RootState } from './store'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useAppStore: () => AppStore = useStore
React Redux v9.1.0 добавляет метод .withTypes для каждого из этих хуков, аналогично методу .withTypes в createAsyncThunk из Redux Toolkit.
Теперь настройка выглядит так:
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { AppDispatch, AppStore, RootState } from './store'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()
Использование в приложении
Определение типов состояния среза и действий
Каждый файл среза должен определять тип для своего начального состояния, чтобы createSlice мог корректно вывести тип state в каждом case reducer.
Все генерируемые действия должны определяться с использованием типа PayloadAction<T> из Redux Toolkit, который принимает тип поля action.payload в качестве аргумента.
Тип RootState можно безопасно импортировать из файла хранилища. Это циклический импорт, но компилятор TypeScript корректно обрабатывает это для типов. Это может потребоваться для таких задач, как написание функций-селекторов.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'
// Define a type for the slice state
interface CounterState {
value: number
}
// Define the initial state using that type
const initialState: CounterState = {
value: 0
}
export const counterSlice = createSlice({
name: 'counter',
// `createSlice` will infer the state type from the `initialState` argument
initialState,
reducers: {
increment: state => {
state.value += 1
},
decrement: state => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
// Other code such as selectors can use the imported `RootState` type
export const selectCount = (state: RootState) => state.counter.value
export default counterSlice.reducer
Созданные генераторы действий будут корректно типизированы для приёма аргумента payload на основе типа PayloadAction<T>, указанного для редюсера. Например, incrementByAmount требует аргумент типа number.
В некоторых случаях TypeScript может излишне ужесточить тип начального состояния. Если это произошло, вы можете обойти это приведением типа initial state через as вместо явного объявления типа переменной:
// Workaround: cast state instead of declaring variable type
const initialState = {
value: 0
} as CounterState
Используйте типизированные хуки в компонентах
В файлах компонентов импортируйте предварительно типизированные хуки вместо стандартных из React Redux.
import React, { useState } from 'react'
import { useAppSelector, useAppDispatch } from 'app/hooks'
import { decrement, increment } from './counterSlice'
export function Counter() {
// The `state` arg is correctly typed as `RootState` already
const count = useAppSelector(state => state.counter.value)
const dispatch = useAppDispatch()
// omit rendering logic
}
ESLint поможет вашей команде легко импортировать нужные хуки. Правило typescript-eslint/no-restricted-imports может показывать предупреждение при случайном использовании неправильного импорта.
Пример добавления в конфиг ESLint:
"no-restricted-imports": "off",
"@typescript-eslint/no-restricted-imports": [
"warn",
{
"name": "react-redux",
"importNames": ["useSelector", "useDispatch"],
"message": "Use typed hooks `useAppDispatch` and `useAppSelector` instead."
}
],
Типизация дополнительной Redux-логики
Проверка типов редюсеров
Редюсеры — чистые функции, принимающие текущее state и входящее action, и возвращающие новое состояние.
При использовании createSlice из Redux Toolkit редко требуется отдельно типизировать редюсер. Если вы пишете автономный редюсер, обычно достаточно объявить тип начального состояния initialState и указать для action тип UnknownAction:
import { UnknownAction } from 'redux'
interface CounterState {
value: number
}
const initialState: CounterState = {
value: 0
}
export default function counterReducer(
state = initialState,
action: UnknownAction
) {
// logic here
}
Однако Redux Core также экспортирует тип Reducer<State, Action>, который можно использовать.
Проверка типов middleware
Middleware — механизм расширения хранилища Redux. Цепочка middleware оборачивает метод dispatch, имея доступ к dispatch и getState.
Redux Core экспортирует тип Middleware для корректной типизации функций middleware:
export interface Middleware<
DispatchExt = {}, // optional override return behavior of `dispatch`
S = any, // type of the Redux store state
D extends Dispatch = Dispatch // type of the dispatch method
>
Кастомный middleware должен использовать тип Middleware с передачей generic-параметров для S (состояние) и D (диспатч) при необходимости:
import { Middleware } from 'redux'
import { RootState } from '../store'
export const exampleMiddleware: Middleware<
{}, // Most middleware do not modify the dispatch return value
RootState
> = storeApi => next => action => {
const state = storeApi.getState() // correctly typed as RootState
}
При использовании typescript-eslint правило @typescript-eslint/ban-types может выдавать ошибку при использовании {} для типа диспатча. Рекомендуемые им изменения некорректны и сломают типы хранилища — отключите правило для этой строки и продолжайте использовать {}.
Generic-параметр для диспатча требуется только при дополнительных диспатчах thunk-внутри middleware.
При использовании type RootState = ReturnType<typeof store.getState> можно избежать циклической ссылки типов между middleware и хранилищем, изменив определение RootState на:
const rootReducer = combineReducers({ ... });
type RootState = ReturnType<typeof rootReducer>;
Пример изменения определения RootState с Redux Toolkit:
// instead of defining the reducers in the reducer field of configureStore, combine them here:
const rootReducer = combineReducers({ counter: counterReducer })
// then set rootReducer as the reducer object of configureStore
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(yourMiddleware)
})
type RootState = ReturnType<typeof rootReducer>
Проверка типов Redux Thunk
Redux Thunk — стандартный middleware для написания синхронной и асинхронной логики, взаимодействующей с Redux-хранилищем. Функция thunk получает dispatch и getState в качестве параметров. Redux Thunk предоставляет встроенный тип ThunkAction, который можно использовать для типизации этих аргументов:
export type ThunkAction<
R, // Return type of the thunk function
S, // state type used by getState
E, // any "extra argument" injected into the thunk
A extends Action // known types of actions that can be dispatched
> = (dispatch: ThunkDispatch<S, E, A>, getState: () => S, extraArgument: E) => R
Обычно требуется указать generic-аргументы R (тип возвращаемого значения) и S (тип состояния). К сожалению, TypeScript не позволяет указывать только некоторые generic-аргументы, поэтому стандартные значения для остальных аргументов: unknown для E и UnknownAction для A:
import { UnknownAction } from 'redux'
import { sendMessage } from './store/chat/actions'
import { RootState } from './store'
import { ThunkAction } from 'redux-thunk'
export const thunkSendMessage =
(message: string): ThunkAction<void, RootState, unknown, UnknownAction> =>
async dispatch => {
const asyncResp = await exampleAPI()
dispatch(
sendMessage({
message,
user: asyncResp,
timestamp: new Date().getTime()
})
)
}
function exampleAPI() {
return Promise.resolve('Async Chat Bot')
}
Чтобы избежать повторений, можно один раз определить переиспользуемый тип AppThunk в файле хранилища, а затем использовать его при написании thunk-функций:
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
UnknownAction
>
Важно: это предполагает отсутствие значимого возвращаемого значения у thunk. Если ваш thunk возвращает промис и требуется использовать этот промис после диспатча, используйте конструкцию AppThunk<Promise<SomeReturnType>>.
Помните, что стандартный хук useDispatch не распознаёт thunk'и, поэтому диспатч thunk'а вызовет ошибку типизации. Обязательно используйте обновлённую форму Dispatch в компонентах, которая распознаёт thunk'и как допустимый тип для диспатча.
Использование с React Redux
Хотя React Redux — отдельная библиотека от самого Redux, он обычно используется с React.
Полное руководство по правильному использованию React Redux с TypeScript см. в разделе "Статическая типизация" документации React Redux. Здесь мы выделим основные паттерны.
Типы для React Redux поддерживаются в DefinitelyTyped, но включены как зависимость пакета react-redux, поэтому должны устанавливаться автоматически. Если требуется установить их вручную:
npm install @types/react-redux
Типизация хука useSelector
Объявите тип параметра state в функции-селекторе, и возвращаемый тип useSelector будет автоматически соответствовать возвращаемому типу селектора:
interface RootState {
isOn: boolean
}
// TS infers type: (state: RootState) => boolean
const selectIsOn = (state: RootState) => state.isOn
// TS infers `isOn` is boolean
const isOn = useSelector(selectIsOn)
Это также можно сделать инлайн:
const isOn = useSelector((state: RootState) => state.isOn)
Однако рекомендуется создать предварительно типизированный хук useAppSelector со встроенным правильным типом state.
Типизация хука useDispatch
По умолчанию возвращаемое значение useDispatch имеет стандартный тип Dispatch из основных типов Redux, поэтому дополнительные объявления не требуются:
const dispatch = useDispatch()
Однако рекомендуется создать предварительно типизированный хук useAppDispatch со встроенным правильным типом Dispatch.
Типизация HOC connect
При использовании connect следует применять тип ConnectedProps<T>, экспортируемый из @types/react-redux^7.1.2, для автоматического вывода типов пропсов из connect. Это требует разделения вызова connect(mapState, mapDispatch)(MyComponent) на две части:
import { connect, ConnectedProps } from 'react-redux'
interface RootState {
isOn: boolean
}
const mapState = (state: RootState) => ({
isOn: state.isOn
})
const mapDispatch = {
toggleOn: () => ({ type: 'TOGGLE_IS_ON' })
}
const connector = connect(mapState, mapDispatch)
// The inferred type will look like:
// {isOn: boolean, toggleOn: () => void}
type PropsFromRedux = ConnectedProps<typeof connector>
type Props = PropsFromRedux & {
backgroundColor: string
}
const MyComponent = (props: Props) => (
<div style={{ backgroundColor: props.backgroundColor }}>
<button onClick={props.toggleOn}>
Toggle is {props.isOn ? 'ON' : 'OFF'}
</button>
</div>
)
export default connector(MyComponent)
Использование с Redux Toolkit
В разделе Стандартная настройка проекта Redux Toolkit с TypeScript уже рассмотрены стандартные паттерны использования configureStore и createSlice, а страница "Использование с TypeScript" в документации Redux Toolkit подробно охватывает все API RTK.
Дополнительные паттерны типизации, часто встречающиеся при работе с RTK:
Типизация configureStore
configureStore автоматически выводит тип значения состояния из предоставленной корневой функции-редюсера, поэтому явные объявления типов обычно не требуются.
Если вы хотите добавить дополнительное middleware в хранилище, обязательно используйте специализированные методы .concat() и .prepend(), доступные в массиве, возвращаемом getDefaultMiddleware(), так как они корректно сохраняют типы добавляемого middleware. (Использование обычного JS-оператора spread часто приводит к потере этих типов.)
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware()
.prepend(
// correctly typed middlewares can just be used
additionalMiddleware,
// you can also type middlewares manually
untypedMiddleware as Middleware<
(action: Action<'specialAction'>) => number,
RootState
>
)
// prepend and concat calls can be chained
.concat(logger)
})
Сопоставление действий
Создаваемые RTK генераторы действий имеют метод match, который действует как type predicate. Вызов someActionCreator.match(action) выполнит строковое сравнение с action.type, и при использовании в условии сузит тип action до корректного TS-типа:
const increment = createAction<number>('increment')
function test(action: Action) {
if (increment.match(action)) {
// action.payload inferred correctly here
const num = 5 + action.payload
}
}
Это особенно полезно при проверке типов действий в Redux middleware, например в кастомном middleware, redux-observable или методе filter из RxJS.
Типизация createSlice
Определение отдельных case reducers
Если у вас слишком много case reducers и их определение внутри функции было бы громоздким, или вы хотите переиспользовать их между срезами, вы можете определить их вне вызова createSlice и типизировать как CaseReducer:
type State = number
const increment: CaseReducer<State, PayloadAction<number>> = (state, action) =>
state + action.payload
createSlice({
name: 'test',
initialState: 0,
reducers: {
increment
}
})
Типизация extraReducers
При добавлении поля extraReducers в createSlice обязательно используйте "builder callback" форму, так как "plain object" форма не может корректно вывести типы действий. Передача RTK-генератора действий в builder.addCase() правильно определит тип action:
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// fill in primary logic here
},
extraReducers: builder => {
builder.addCase(fetchUserById.pending, (state, action) => {
// both `state` and `action` are now correctly typed
// based on the slice state and the `pending` action creator
})
}
})
Типизация prepare-колбэков
Если вам нужно добавить свойства meta или error в действие, или кастомизировать payload, используйте нотацию prepare для определения case reducer. Пример с TypeScript:
const blogSlice = createSlice({
name: 'blogData',
initialState,
reducers: {
receivedAll: {
reducer(
state,
action: PayloadAction<Page[], string, { currentPage: number }>
) {
state.all = action.payload
state.meta = action.meta
},
prepare(payload: Page[], currentPage: number) {
return { payload, meta: { currentPage } }
}
}
}
})
Исправление циклических зависимостей типов в экспортируемых срезах
В редких случаях может потребоваться экспортировать редюсер среза с явным указанием типа, чтобы решить проблему циклической зависимости типов. Пример:
export default counterSlice.reducer as Reducer<Counter>
Типизация createAsyncThunk
Для базового использования достаточно указать для createAsyncThunk тип единственного аргумента колбэка создания payload. Также убедитесь, что возвращаемое значение колбэка имеет правильный тип:
const fetchUserById = createAsyncThunk(
'users/fetchById',
// Declare the type your function argument here:
async (userId: number) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`)
// Inferred return type: Promise<MyData>
return (await response.json()) as MyData
}
)
// the parameter of `fetchUserById` is automatically inferred to `number` here
// and dispatching the resulting thunkAction will return a Promise of a correctly
// typed "fulfilled" or "rejected" action.
const lastReturnedAction = await store.dispatch(fetchUserById(3))
Для модификации типов параметра thunkApi (например, указания типа state из getState()) передайте первые два дженерика (тип возврата и аргумент payload), плюс объект с нужными полями "thunkApi":
const fetchUserById = createAsyncThunk<
// Return type of the payload creator
MyData,
// First argument to the payload creator
number,
{
// Optional fields for defining thunkApi field types
dispatch: AppDispatch
state: State
extra: {
jwt: string
}
}
>('users/fetchById', async (userId, thunkApi) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
headers: {
Authorization: `Bearer ${thunkApi.extra.jwt}`
}
})
return (await response.json()) as MyData
})
Типизация createEntityAdapter
Использование createEntityAdapter с TypeScript зависит от того, нормализованы ли ваши сущности по свойству id или требуется кастомный selectId.
Если сущности нормализованы по свойству id, createEntityAdapter требует только указания типа сущности в качестве дженерика:
interface Book {
id: number
title: string
}
// no selectId needed here, as the entity has an `id` property we can default to
const booksAdapter = createEntityAdapter<Book>({
sortComparer: (a, b) => a.title.localeCompare(b.title)
})
const booksSlice = createSlice({
name: 'books',
// The type of the state is inferred here
initialState: booksAdapter.getInitialState(),
reducers: {
bookAdded: booksAdapter.addOne,
booksReceived(state, action: PayloadAction<{ books: Book[] }>) {
booksAdapter.setAll(state, action.payload.books)
}
}
})
Если же сущность нормализуется по другому свойству, рекомендуется передать кастомную функцию selectId с аннотацией типа. Это позволяет корректно вывести тип ID без ручного указания:
interface Book {
bookId: number
title: string
// ...
}
const booksAdapter = createEntityAdapter({
selectId: (book: Book) => book.bookId,
sortComparer: (a, b) => a.title.localeCompare(b.title)
})
const booksSlice = createSlice({
name: 'books',
// The type of the state is inferred here
initialState: booksAdapter.getInitialState(),
reducers: {
bookAdded: booksAdapter.addOne,
booksReceived(state, action: PayloadAction<{ books: Book[] }>) {
booksAdapter.setAll(state, action.payload.books)
}
}
})
Дополнительные рекомендации
Используйте Hooks API React Redux
Рекомендуем использовать Hooks API React Redux по умолчанию. Этот подход значительно проще для TypeScript: useSelector — простой хук, принимающий функцию-селектор, а тип возврата легко выводится из типа аргумента state.
Хотя connect всё ещё работает и может быть типизирован, сделать это корректно значительно сложнее.
Избегайте объединений типов действий
Мы настоятельно рекомендуем не пытаться создавать объединённые типы (unions) для действий, поскольку это не приносит реальной пользы и может вводить компилятор в заблуждение. Подробное объяснение проблемы см. в статье Не создавайте объединённые типы для действий Redux Ленца Вебера, одного из сопровождающих RTK.
Кроме того, если вы используете createSlice, вы уже можете быть уверены, что все действия, определённые в этом срезе, обрабатываются корректно.
Дополнительные материалы
Для получения дополнительной информации ознакомьтесь с этими ресурсами:
-
Документация библиотек Redux:
- React Redux: Статическая типизация: примеры использования API React Redux с TypeScript
- Redux Toolkit: Использование с TypeScript: примеры использования API Redux Toolkit с TypeScript
-
Гайды по React + Redux + TypeScript:
- React+TypeScript Cheatsheet: подробное руководство по использованию React с TypeScript
- React + Redux в TypeScript: обширная информация о паттернах использования React и Redux с TypeScript
- Примечание: хотя в этом руководстве есть полезная информация, многие из показанных паттернов противоречат нашим рекомендациям на этой странице (например, использование объединённых типов действий). Мы приводим ссылку для полноты картины
-
Другие статьи: