TypeScript との連携
- TypeScript を使用した Redux アプリの標準的なセットアップパターン
- Redux ロジックの一部を正しく型付けするテクニック
- TypeScript の構文と用語の理解
- ジェネリクスやユーティリティ型のような TypeScript の概念に精通していること
- React Hooksの知識
概要
TypeScript は、ソースコードのコンパイル時チェックを提供する、JavaScript の型付きスーパーセットです。Redux と共に使用すると、TypeScript は以下を提供できます。
- Reducer、状態、アクションクリエーター、UI コンポーネントの型安全性
- 型付きコードの容易なリファクタリング
- チーム環境における優れた開発者体験
Redux アプリケーションでは TypeScript の使用を強く推奨します。ただし、すべてのツールと同様に、TypeScript にはトレードオフがあります。追加のコードの記述、TS 構文の理解、アプリケーションの構築という点で複雑さが増します。同時に、開発の初期段階でエラーを検出し、より安全で効率的なリファクタリングを可能にし、既存のソースコードのドキュメントとして機能することにより、価値を提供します。
TypeScript の実際的な使用は、追加のオーバーヘッドを正当化するだけの十分な価値と利点をもたらすと信じていますが、トレードオフを評価し、独自のアプリケーションで TS を使用する価値があるかどうかを決定する時間を取る必要があります。
Redux コードの型チェックには、複数の方法があります。このページでは、Redux と TypeScript を連携して使用する際の標準的な推奨パターンを示しており、網羅的なガイドではありません。これらのパターンに従うことで、優れた TS 使用エクスペリエンスが得られ、型安全性とコードベースに追加する必要がある型宣言の量のバランスが最適になります。
TypeScript を使用した標準的な Redux Toolkit プロジェクトのセットアップ
一般的な Redux プロジェクトでは、Redux Toolkit と React Redux を連携して使用していることを前提としています。
Redux Toolkit (RTK) は、モダンな Redux ロジックを記述するための標準的なアプローチです。RTK は既に TypeScript で記述されており、その API は TypeScript の使用に適したエクスペリエンスを提供するように設計されています。
React Redux の型定義は、NPM の個別の@types/react-redux
型定義パッケージにあります。ライブラリ関数の型付けに加えて、これらの型は、Redux ストアと React コンポーネント間の型安全なインターフェースを記述しやすくするためのヘルパーもエクスポートします。
React Redux v7.2.3 以降、react-redux
パッケージは @types/react-redux
に依存しているため、型定義はライブラリと一緒に自動的にインストールされます。それ以外の場合は、手動でインストールする必要があります(通常は npm install @types/react-redux
)。
Create-React-App 用の Redux+TS テンプレートには、既にこれらのパターンが設定された動作例が含まれています。
ルート状態と Dispatch 型の定義
configureStore を使用する場合、追加の型付けはほとんど必要ありません。ただし、必要に応じて参照できるように、RootState
型と Dispatch
型を抽出する必要があります。ストア自体からこれらの型を推論することで、状態スライスを追加したり、ミドルウェアの設定を変更したりした場合に、正しく更新されます。
これらは型なので、app/store.ts
などのストア設定ファイルから直接エクスポートし、他のファイルに直接インポートしても安全です。
import { configureStore } from '@reduxjs/toolkit'
// ...
export const store = configureStore({
reducer: {
posts: postsReducer,
comments: commentsReducer,
users: usersReducer
}
})
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch
型付きフックの定義
各コンポーネントに RootState
型と AppDispatch
型をインポートすることもできますが、アプリケーションで使用するための useDispatch
フックと useSelector
フックの事前型付きバージョンを作成する方が良いでしょう。これは、いくつかの理由で重要です。
useSelector
の場合、毎回(state: RootState)
を入力する必要がなくなります。useDispatch
の場合、デフォルトのDispatch
型は、thunk やその他のミドルウェアを認識しません。thunk を正しくディスパッチするには、thunk ミドルウェアの型を含むストアから特定のカスタマイズされたAppDispatch
型を使用し、それを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 では、Redux Toolkit の.withTypes
メソッドと同様の、これらのフックそれぞれに新しい .withTypes
メソッドが追加されました。
セットアップは次のようになります。
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
は各ケース reducer で state
の型を正しく推論できます。
生成されたすべてのアクションは、Redux Toolkit の PayloadAction<T>
型を使用して定義する必要があります。この型は、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
生成されたアクションクリエーターは、PayloadAction<T>
型で reducer に提供した payload
引数に基づいて、正しく型付けされます。たとえば、incrementByAmount
は引数として number
を必要とします。
場合によっては、TypeScript が初期状態の型を不必要に厳しくする可能性があります。その場合は、変数の型を宣言する代わりに、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 ロジックの型付け
Reducer の型チェック
Reducer は、現在の state
と受信した action
を引数として受け取り、新しい状態を返す純粋関数です。
Redux Toolkit の createSlice
を使用している場合、Reducer を個別に型付けする必要はほとんどありません。スタンドアロンの Reducer を実際に記述する場合は、通常、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 コアは Reducer<State, Action>
型もエクスポートしており、これを使用することもできます。
ミドルウェアの型チェック
ミドルウェアは、Reduxストアの拡張メカニズムです。ミドルウェアは、ストアのdispatch
メソッドをラップするパイプラインに構成され、ストアのdispatch
メソッドとgetState
メソッドにアクセスできます。
Reduxコアは、ミドルウェア関数の型を正しく指定するために使用できる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
型を使用し、必要に応じてS
(状態)とD
(dispatch)のジェネリック引数を渡す必要があります。
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
を使用している場合、dispatch値に{}
を使用すると、@typescript-eslint/ban-types
ルールがエラーを報告することがあります。推奨される変更は正しくなく、Reduxストアの型が壊れるため、この行についてはルールを無効にして{}
を使い続ける必要があります。
dispatchジェネリックは、ミドルウェア内で追加のthunkをdispatchする場合にのみ必要になる可能性があります。
type RootState = ReturnType<typeof store.getState>
を使用する場合、ミドルウェアとストアの定義間の循環型参照は、RootState
の型定義を切り替えることで回避できます。
const rootReducer = combineReducers({ ... });
type RootState = ReturnType<typeof rootReducer>;
Redux Toolkitを使用した例における`RootState`の型定義の変更
//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は、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
通常、R
(戻り値の型)とS
(状態)のジェネリック引数を指定する必要があります。残念ながら、TSでは一部のジェネリック引数のみを指定することはできないため、他の引数の通常の値はE
に対してunknown
、A
に対してUnknownAction
になります。
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がPromiseを返し、thunkのdispatch後に返されたPromiseを使用する場合は、AppThunk<Promise<SomeReturnType>>
として使用します。
デフォルトのuseDispatch
フックはthunkを認識しないため、thunkをdispatchすると型エラーが発生することに注意してください。 thunkを許容できる型として認識するコンポーネントで更新されたDispatch
形式を使用するようにしてください。
React Reduxでの使用方法
React ReduxはRedux自体とは別のライブラリですが、Reactと一般的に使用されます。
TypeScriptでReact Reduxを正しく使用する方法に関する完全なガイドについては、React Reduxドキュメントの「静的型付け」ページを参照してください。このセクションでは、標準のパターンについて説明します。
TypeScriptを使用している場合、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)
ただし、正しい型のstate
を組み込んだ、事前に型付けされたuseAppSelector
フックを作成することをお勧めします。
useDispatch
フックの型付け
デフォルトでは、useDispatch
の戻り値はReduxコア型によって定義された標準のDispatch
型であるため、宣言は必要ありません。
const dispatch = useDispatch()
ただし、正しい型のDispatch
を組み込んだ、事前に型付けされたuseAppDispatch
フックを作成することをお勧めします。
connect
高階コンポーネントの型付け
まだconnect
を使用している場合は、@types/react-redux^7.1.2
によってエクスポートされるConnectedProps<T>
型を使用して、connect
からpropsの型を自動的に推論する必要があります。これには、connect(mapState, mapDispatch)(MyComponent)
呼び出しを2つの部分に分割する必要があります。
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での使用方法
TypeScriptを使用した標準的なRedux Toolkitプロジェクトの設定セクションでは、configureStore
とcreateSlice
の通常の使用方法パターンについて既に説明しており、Redux Toolkitの「TypeScriptでの使用方法」ページでは、すべてのRTK APIについて詳しく説明しています。
RTKを使用する場合に一般的に見られる追加の型付けパターンを次に示します。
configureStore
の型付け
configureStore
は、提供されたルートリデューサ関数から状態値の型を推論するため、特定の型宣言は必要ありません。
ストアに追加のミドルウェアを追加する場合は、getDefaultMiddleware()
によって返される配列に含まれる特殊な.concat()
メソッドと.prepend()
メソッドを使用してください。これにより、追加しているミドルウェアの型が正しく保持されます。(通常のJS配列のスプレッド演算子を使用すると、これらの型が失われることがよくあります。)
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
メソッドがあります。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-observable
、RxJSのfilter
メソッドなど、Reduxミドルウェアでアクション型をチェックする場合に特に便利です。
createSlice
の型付け
個別のケースリデューサの定義
ケースリデューサが多すぎてインラインで定義するのが面倒な場合、またはスライス間でケースリデューサを再利用したい場合は、createSlice
呼び出しの外側に定義し、CaseReducer
として型付けすることもできます。
type State = number
const increment: CaseReducer<State, PayloadAction<number>> = (state, action) =>
state + action.payload
createSlice({
name: 'test',
initialState: 0,
reducers: {
increment
}
})
extraReducers
の型付け
createSlice
でextraReducers
フィールドを追加する場合は、「ビルダーコールバック」形式を使用してください。「プレーンオブジェクト」形式では、アクション型を正しく推論できません。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
表記を使用する必要があります。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
に提供する必要がある型は、ペイロード作成コールバックの単一引数の型だけです。コールバックの戻り値も正しく型付けされていることを確認する必要があります。
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))
getState()
によって返されるstate
の型を提供するなど、thunkApi
パラメーターの型を変更する必要がある場合は、戻り値の型とペイロード引数の最初の2つのジェネリック引数と、オブジェクトに関連する「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
の型付けでは、エンティティ型を単一のジェネリック引数として指定するだけで済みます。これは通常、次のようになります。
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)
}
}
})
追加の推奨事項
React Redux Hooks APIの使用
React Redux Hooks APIをデフォルトのアプローチとして使用することをお勧めします。Hooks APIはTypeScriptで使用するとはるかに簡単です。useSelector
はセレクター関数を受け取る単純なフックであり、戻り値はstate
引数の型から簡単に推論できます。
connect
はまだ正常に機能し、型付けできますが、正しく型付けするのははるかに困難です。
アクション型ユニオンの回避
アクション型のユニオンを作成しようとしないことを特に推奨します。これは実際的なメリットがなく、コンパイラをある意味で誤解させるためです。RTKのメンテナーであるLenz Weberの投稿Reduxアクション型でユニオン型を作成しないでくださいで、これがなぜ問題なのかを説明しています。
さらに、createSlice
を使用している場合、そのスライスによって定義されたすべてのアクションが正しく処理されていることが既にわかっています。
リソース
詳細については、以下の追加リソースを参照してください。
- Reduxライブラリドキュメント
- React Reduxドキュメント:静的型付け:TypeScriptでReact Redux APIを使用する方法の例
- Redux Toolkitドキュメント:TypeScriptでの使用方法:TypeScriptでRedux Toolkit APIを使用する方法の例
- React + Redux + TypeScriptガイド
- React+TypeScriptチートシート:TypeScriptでReactを使用するための包括的なガイド
- React + Redux in TypeScriptガイド:TypeScriptでReactとReduxを使用するためのパターンの詳細情報
- 注:このガイドには役立つ情報が含まれていますが、示されているパターンの多くはこのページで示されている推奨される方法(アクション型ユニオンの使用など)に反しています。完全性のためにこれをリンクしています。
- その他の資料