本文へスキップ

TypeScript との連携

学習内容
  • TypeScript を使用した Redux アプリの標準的なセットアップパターン
  • Redux ロジックの一部を正しく型付けするテクニック
前提条件

概要

TypeScript は、ソースコードのコンパイル時チェックを提供する、JavaScript の型付きスーパーセットです。Redux と共に使用すると、TypeScript は以下を提供できます。

  1. Reducer、状態、アクションクリエーター、UI コンポーネントの型安全性
  2. 型付きコードの容易なリファクタリング
  3. チーム環境における優れた開発者体験

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などのストア設定ファイルから直接エクスポートし、他のファイルに直接インポートしても安全です。

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()

以前は、アプリ設定でフックを「事前型付け」するアプローチは少し異なっていました。結果は、以下のスニペットのようになります。

app/hooks.ts
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 メソッドが追加されました。

セットアップは次のようになります。

app/hooks.ts
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 コンパイラは型の処理を正しく行えます。セレクター関数の記述など、いくつかのユースケースで必要になる場合があります。

features/counter/counterSlice.ts
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 から標準フックではなく、事前型付きフックをインポートします。

features/counter/Counter.tsx
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 値の型を宣言し、actionUnknownAction として型付けするだけで十分です。

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関数は、dispatchgetStateをパラメーターとして受け取ります。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に対してunknownAに対して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プロジェクトの設定セクションでは、configureStorecreateSliceの通常の使用方法パターンについて既に説明しており、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の型付け

createSliceextraReducersフィールドを追加する場合は、「ビルダーコールバック」形式を使用してください。「プレーンオブジェクト」形式では、アクション型を正しく推論できません。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を使用している場合、そのスライスによって定義されたすべてのアクションが正しく処理されていることが既にわかっています。

リソース

詳細については、以下の追加リソースを参照してください。