メインコンテンツにスキップ

TypeScript での使用

学習内容
  • TypeScript で各 Redux Toolkit API を使用する方法の詳細

はじめに

Redux Toolkit は TypeScript で記述されており、その API は TypeScript アプリケーションとの優れた統合を可能にするように設計されています。

このページでは、Redux Toolkit に含まれるさまざまな API のそれぞれと、TypeScript で正しく型付けする方法について詳しく説明します。

Redux Toolkit と React Redux をセットアップして TypeScript で動作させる方法の簡単な概要については、TypeScript クイックスタートチュートリアルページを参照してください.

情報

このページで説明されていない型に関する問題が発生した場合は、議論のためにissue をオープンしてください。

configureStore

configureStore の基本的な使い方は、TypeScript クイックスタートチュートリアルページに示されています。ここでは、役立つ可能性のある追加の詳細を示します。

State 型の取得

State 型を取得する最も簡単な方法は、ルートリデューサーを事前に定義し、その ReturnType を抽出することです。型名 State は通常使い古されているため、混乱を防ぐために、RootState のように異なる名前を付けることをお勧めします。

import { combineReducers } from '@reduxjs/toolkit'
const rootReducer = combineReducers({})
export type RootState = ReturnType<typeof rootReducer>

または、自分で rootReducer を作成するのではなく、スライスリデューサーを直接 configureStore() に渡すことを選択した場合は、ルートリデューサーを正しく推論するために、型をわずかに変更する必要があります

import { configureStore } from '@reduxjs/toolkit'
// ...
const store = configureStore({
reducer: {
one: oneSlice.reducer,
two: twoSlice.reducer,
},
})
export type RootState = ReturnType<typeof store.getState>

export default store

リデューサーを直接 configureStore() に渡し、ルートリデューサーを明示的に定義しない場合、rootReducer への参照はありません。代わりに、State 型を取得するために store.getState を参照できます。

import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './rootReducer'
const store = configureStore({
reducer: rootReducer,
})
export type RootState = ReturnType<typeof store.getState>

Dispatch 型の取得

ストアから Dispatch 型を取得する場合は、ストアを作成した後に抽出できます。型名 Dispatch は通常使い古されているため、混乱を防ぐために、AppDispatch のように異なる名前を付けることをお勧めします。また、以下に示す useAppDispatch のようなフックをエクスポートし、useDispatch を呼び出す場所で使用すると、より便利になる場合があります。

import { configureStore } from '@reduxjs/toolkit'
import { useDispatch } from 'react-redux'
import rootReducer from './rootReducer'

const store = configureStore({
reducer: rootReducer,
})

export type AppDispatch = typeof store.dispatch
export const useAppDispatch = useDispatch.withTypes<AppDispatch>() // Export a hook that can be reused to resolve types

export default store

Dispatch 型の正しい型付け

dispatch 関数の型は、middleware オプションから直接推論されます。したがって、正しく型付けされたミドルウェアを追加すると、dispatch はすでに正しく型付けされているはずです。

TypeScript は、スプレッド演算子を使用して配列を結合するときに配列型を拡大することが多いため、getDefaultMiddleware() によって返される Tuple.concat(...) メソッドと .prepend(...) メソッドを使用することをお勧めします。

import { configureStore } from '@reduxjs/toolkit'
import additionalMiddleware from 'additional-middleware'
import logger from 'redux-logger'
// @ts-ignore
import untypedMiddleware from 'untyped-middleware'
import rootReducer from './rootReducer'

export type RootState = ReturnType<typeof rootReducer>
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),
})

export type AppDispatch = typeof store.dispatch

export default store

getDefaultMiddleware を使用しない Tuple の使用

getDefaultMiddleware の使用を完全にスキップする場合は、middleware 配列を型安全に作成するために Tuple を使用する必要があります。このクラスはデフォルトの JavaScript Array 型を拡張したものですが、.concat(...) の型と追加の .prepend(...) メソッドの型が変更されています。

import { configureStore, Tuple } from '@reduxjs/toolkit'

configureStore({
reducer: rootReducer,
middleware: () => new Tuple(additionalMiddleware, logger),
})

抽出した Dispatch 型を React Redux で使用する

デフォルトでは、React Redux の useDispatch フックには、ミドルウェアを考慮に入れる型は含まれていません。ディスパッチ時に dispatch 関数のより具体的な型が必要な場合は、返される dispatch 関数の型を指定するか、カスタム型の useSelector バージョンを作成できます。詳細については、React Redux のドキュメントを参照してください。

createAction

ほとんどの場合、action.type をリテラルで定義する必要はないため、以下を使用できます

createAction<number>('test')

これにより、作成されたアクションは PayloadActionCreator<number, string> 型になります。

一部のセットアップでは、action.type のリテラル型が必要になります。残念ながら、TypeScript の型定義では、手動で定義された型パラメーターと推論された型パラメーターを混在させることはできないため、ジェネリック定義と実際の JavaScript コードの両方で type を指定する必要があります

createAction<number, 'test'>('test')

重複なしでこれを記述する別の方法を探している場合は、準備コールバックを使用すると、両方の型パラメーターを引数から推論できるため、アクション型を指定する必要がなくなります。

function withPayloadType<T>() {
return (t: T) => ({ payload: t })
}
createAction('test', withPayloadType<string>())

リテラル型付き action.type の代わりに

たとえば、case ステートメントでペイロードを正しく型付けするために、判別ユニオンの判別子として action.type を使用している場合は、この代替案に興味があるかもしれません

作成されたアクションクリエイターには、型述語として機能する match メソッドがあります

const increment = createAction<number>('increment')
function test(action: Action) {
if (increment.match(action)) {
// action.payload inferred correctly here
action.payload
}
}

この match メソッドは、redux-observable や RxJS の filter メソッドと組み合わせて使用する場合にも非常に便利です。

createReducer

型安全なリデューサー引数オブジェクトの構築

createReducer の 2 番目のパラメーターは、ActionReducerMapBuilder インスタンスを受け取るコールバックです

const increment = createAction<number, 'increment'>('increment')
const decrement = createAction<number, 'decrement'>('decrement')
createReducer(0, (builder) =>
builder
.addCase(increment, (state, action) => {
// action is inferred correctly here
})
.addCase(decrement, (state, action: PayloadAction<string>) => {
// this would error out
}),
)

builder.addMatcher の型付け

builder.addMatcher への最初の matcher 引数として、型述語関数を使用する必要があります。その結果、2 番目の reducer 引数の action 引数は TypeScript によって推論できます

function isNumberValueAction(action: UnknownAction): action is PayloadAction<{ value: number }> {
return typeof action.payload.value === 'number'
}

createReducer({ value: 0 }, builder =>
builder.addMatcher(isNumberValueAction, (state, action) => {
state.value += action.payload.value
})
})

createSlice

createSlice はアクションとリデューサーを作成するため、ここでは型安全性を心配する必要はありません。アクション型はインラインで指定できます

const slice = createSlice({
name: 'test',
initialState: 0,
reducers: {
increment: (state, action: PayloadAction<number>) => state + action.payload,
},
})
// now available:
slice.actions.increment(2)
// also available:
slice.caseReducers.increment(0, { type: 'increment', payload: 5 })

ケースリデューサーが多すぎてインラインで定義すると煩雑になる場合や、スライス間でケースリデューサーを再利用したい場合は、createSlice 呼び出しの外で定義し、CaseReducer として型付けすることもできます

type State = number
const increment: CaseReducer<State, PayloadAction<number>> = (state, action) =>
state + action.payload

createSlice({
name: 'test',
initialState: 0,
reducers: {
increment,
},
})

初期状態型の定義

SliceState 型をジェネリックとして createSlice に渡すのは良い考えではないことに気付いたかもしれません。これは、ほとんどの場合、createSlice への後続のジェネリックパラメーターは推論する必要があり、TypeScript は同じ「ジェネリックブロック」内でジェネリック型の明示的な宣言と推論を混在させることができないためです。

標準的なアプローチは、状態のインターフェイスまたは型を宣言し、その型を使用する初期状態値を作成し、初期状態値を createSlice に渡すことです。また、initialState: myInitialState satisfies SliceState as SliceState という構造を使用することもできます。

type SliceState = { state: 'loading' } | { state: 'finished'; data: string }

// First approach: define the initial state using that type
const initialState: SliceState = { state: 'loading' }

createSlice({
name: 'test1',
initialState, // type SliceState is inferred for the state of the slice
reducers: {},
})

// Or, cast the initial state as necessary
createSlice({
name: 'test2',
initialState: { state: 'loading' } satisfies SliceState as SliceState,
reducers: {},
})

これにより、Slice<SliceState, ...> が生成されます。

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 } }
},
},
},
})

スライス用に生成されたアクション型

createSlice は、スライスからの name フィールドとリデューサー関数のフィールド名を組み合わせた 'test/increment' のようなアクション型文字列を生成します。これは、TS の文字列リテラル分析のおかげで、正確な値として強く型付けされます。

また、slice.action.myAction.match 型述語を使用することもできます。これにより、アクションオブジェクトが正確な型に絞り込まれます

const slice = createSlice({
name: 'test',
initialState: 0,
reducers: {
increment: (state, action: PayloadAction<number>) => state + action.payload,
},
})

type incrementType = typeof slice.actions.increment.type
// type incrementType = 'test/increment'

function myCustomMiddleware(action: Action) {
if (slice.actions.increment.match(action)) {
// `action` is narrowed down to the type `PayloadAction<number>` here.
}
}

実際にその型が必要な場合は、残念ながら手動でキャストするしか方法はありません。

extraReducers を使用した型安全性

アクションの type 文字列をリデューサー関数にマップするリデューサールックアップテーブルは、完全に正しく型付けするのが簡単ではありません。これは、createReducercreateSliceextraReducers 引数の両方に影響します。したがって、createReducer と同様に、リデューサーオブジェクト引数を定義するには「ビルダーコールバック」アプローチを使用する必要があります

これは、スライスリデューサーが他のスライスによって生成されたアクションタイプ、またはcreateActionへの特定の呼び出し(createAsyncThunkによって生成されるアクションなど)によって生成されたアクションタイプを処理する必要がある場合に特に役立ちます。

const fetchUserById = createAsyncThunk(
'users/fetchById',
// if you type your function argument here
async (userId: number) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`)
return (await response.json()) as Returned
},
)

interface UsersState {
entities: User[]
loading: 'idle' | 'pending' | 'succeeded' | 'failed'
}

const initialState = {
entities: [],
loading: 'idle',
} satisfies UsersState as UsersState

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
})
},
})

createReducerbuilderと同様に、このbuilderaddMatcherbuilder.matcherの型付けを参照)とaddDefaultCaseを受け入れます。

すべてのフィールドがオプションのペイロード

PayloadAction<Partial<User>>PayloadAction<{value?: string}>のように、すべてのフィールドがオプションであるペイロード型を提供しようとすると、TSはアクション型を正しく推論できない場合があります。

これを回避するには、少なくとも1つのフィールドが渡されるように、カスタムのAtLeastOneユーティリティ型を使用することができます。

type AtLeastOne<T extends Record<string, any>> = keyof T extends infer K
? K extends string
? Pick<T, K & keyof T> & Partial<T>
: never
: never

// Use this type instead of `Partial<MyPayloadType>`
type AtLeastOneUserField = AtLeastOne<User>

createSlice内での非同期サンクの型付け

2.0以降、createSliceでは、コールバック構文を使用してreducers内でサンクを定義できます。

create.asyncThunkメソッドの型付けは、createAsyncThunkと同じように機能しますが、1つの重要な違いがあります。

statedispatchの型は、循環型を引き起こすため、ThunkApiConfigの一部として提供できません

代わりに、必要なときにgetState() as RootStateのように型をアサートする必要があります。また、循環型推論サイクルを中断するために、ペイロード関数の明示的な戻り値の型を含めることもできます。

create.asyncThunk<Todo, string, { rejectValue: { error: string } }>(
// may need to include an explicit return type
async (id: string, thunkApi): Promise<Todo> => {
// Cast types for `getState` and `dispatch` manually
const state = thunkApi.getState() as RootState
const dispatch = thunkApi.dispatch as AppDispatch
try {
const todo = await fetchTodo()
return todo
} catch (e) {
throw thunkApi.rejectWithValue({
error: 'Oh no!',
})
}
},
)

一般的なサンクAPI構成オプションについては、withTypesヘルパーが提供されています。

reducers: (create) => {
const createAThunk = create.asyncThunk.withTypes<{
rejectValue: { error: string }
}>()

return {
fetchTodo: createAThunk<Todo, string>(async (id, thunkApi) => {
throw thunkApi.rejectWithValue({
error: 'Oh no!',
})
}),
fetchTodos: createAThunk<Todo[], string>(async (id, thunkApi) => {
throw thunkApi.rejectWithValue({
error: 'Oh no, not again!',
})
}),
}
}

createSliceのラップ

リデューサーロジックを再利用する必要がある場合、追加の共通動作でリデューサー関数をラップする「高階リデューサー」を作成するのが一般的です。これはcreateSliceでも行うことができますが、createSliceの型の複雑さのため、SliceCaseReducers型とValidateSliceCaseReducers型を非常に特定の方法で使用する必要があります。

そのような「汎用的な」ラップされたcreateSlice呼び出しの例を次に示します。

interface GenericState<T> {
data?: T
status: 'loading' | 'finished' | 'error'
}

const createGenericSlice = <
T,
Reducers extends SliceCaseReducers<GenericState<T>>,
>({
name = '',
initialState,
reducers,
}: {
name: string
initialState: GenericState<T>
reducers: ValidateSliceCaseReducers<GenericState<T>, Reducers>
}) => {
return createSlice({
name,
initialState,
reducers: {
start(state) {
state.status = 'loading'
},
/**
* If you want to write to values of the state that depend on the generic
* (in this case: `state.data`, which is T), you might need to specify the
* State type manually here, as it defaults to `Draft<GenericState<T>>`,
* which can sometimes be problematic with yet-unresolved generics.
* This is a general problem when working with immer's Draft type and generics.
*/
success(state: GenericState<T>, action: PayloadAction<T>) {
state.data = action.payload
state.status = 'finished'
},
...reducers,
},
})
}

const wrappedSlice = createGenericSlice({
name: 'test',
initialState: { status: 'loading' } as GenericState<string>,
reducers: {
magic(state) {
state.status = 'finished'
state.data = 'hocus pocus'
},
},
})

createAsyncThunk

基本的なcreateAsyncThunkの型

最も一般的なユースケースでは、createAsyncThunkの呼び出し自体に対して明示的に型を宣言する必要はありません。

任意の関数の引数と同様に、payloadCreator引数の最初の引数の型を指定するだけで、結果のサンクは入力パラメーターと同じ型を受け入れます。payloadCreatorの戻り値の型も、生成されるすべてのアクションタイプに反映されます。

interface MyData {
// ...
}

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オブジェクトの型付け

thunkApiとして知られるpayloadCreatorの2番目の引数は、サンクミドルウェアからのdispatchgetState、およびextra引数への参照と、rejectWithValueというユーティリティ関数を含むオブジェクトです。payloadCreator内でこれらを使用する場合は、これらの引数の型を推論できないため、いくつかのジェネリック引数を定義する必要があります。また、TSは明示的なジェネリックパラメーターと推論されたジェネリックパラメーターを混在させることができないため、この時点から、ReturnedThunkArgジェネリックパラメーターも定義する必要があります。

thunkApi型のマニュアル定義

これらの引数の型を定義するには、3番目のジェネリック引数としてオブジェクトを渡し、これらのフィールドの一部またはすべてに対して型宣言を行います。

type AsyncThunkConfig = {
/** return type for `thunkApi.getState` */
state?: unknown
/** type for `thunkApi.dispatch` */
dispatch?: Dispatch
/** type of the `extra` argument for the thunk middleware, which will be passed in as `thunkApi.extra` */
extra?: unknown
/** type to be passed into `rejectWithValue`'s first argument that will end up on `rejectedAction.payload` */
rejectValue?: unknown
/** return type of the `serializeError` option callback */
serializedErrorType?: unknown
/** type to be returned from the `getPendingMeta` option callback & merged into `pendingAction.meta` */
pendingMeta?: unknown
/** type to be passed into the second argument of `fulfillWithValue` to finally be merged into `fulfilledAction.meta` */
fulfilledMeta?: unknown
/** type to be passed into the second argument of `rejectWithValue` to finally be merged into `rejectedAction.meta` */
rejectedMeta?: unknown
}
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
})

通常、成功するか、予想されるエラー形式になることがわかっているリクエストを実行する場合は、rejectValueに型を渡し、アクションクリエーターでreturn rejectWithValue(knownPayload)を返すことができます。これにより、リデューサー内だけでなく、createAsyncThunkアクションをディスパッチした後のコンポーネントでもエラーペイロードを参照できます。

interface MyKnownError {
errorMessage: string
// ...
}
interface UserAttributes {
id: string
first_name: string
last_name: string
email: string
}

const updateUser = createAsyncThunk<
// Return type of the payload creator
MyData,
// First argument to the payload creator
UserAttributes,
// Types for ThunkAPI
{
extra: {
jwt: string
}
rejectValue: MyKnownError
}
>('users/update', async (user, thunkApi) => {
const { id, ...userData } = user
const response = await fetch(`https://reqres.in/api/users/${id}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${thunkApi.extra.jwt}`,
},
body: JSON.stringify(userData),
})
if (response.status === 400) {
// Return the known error for future handling
return thunkApi.rejectWithValue((await response.json()) as MyKnownError)
}
return (await response.json()) as MyData
})

statedispatchextra、およびrejectValueのこの表記法は、最初は一般的ではないように見えるかもしれませんが、実際に必要なこれらの型のみを提供できます。たとえば、payloadCreator内でgetStateにアクセスしない場合は、stateの型を提供する必要はありません。rejectValueについても同じことが言えます。潜在的なエラーペイロードにアクセスする必要がない場合は、無視できます。

さらに、createActionによって提供されるaction.payloadmatchに対するチェックを、定義された型の既知のプロパティにアクセスしたい場合の型ガードとして利用できます。例

  • リデューサー内
const usersSlice = createSlice({
name: 'users',
initialState: {
entities: {},
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder.addCase(updateUser.fulfilled, (state, { payload }) => {
state.entities[payload.id] = payload
})
builder.addCase(updateUser.rejected, (state, action) => {
if (action.payload) {
// Since we passed in `MyKnownError` to `rejectValue` in `updateUser`, the type information will be available here.
state.error = action.payload.errorMessage
} else {
state.error = action.error
}
})
},
})
  • コンポーネント内
const handleUpdateUser = async (userData) => {
const resultAction = await dispatch(updateUser(userData))
if (updateUser.fulfilled.match(resultAction)) {
const user = resultAction.payload
showToast('success', `Updated ${user.name}`)
} else {
if (resultAction.payload) {
// Since we passed in `MyKnownError` to `rejectValue` in `updateUser`, the type information will be available here.
// Note: this would also be a good place to do any handling that relies on the `rejectedWithValue` payload, such as setting field errors
showToast('error', `Update failed: ${resultAction.payload.errorMessage}`)
} else {
showToast('error', `Update failed: ${resultAction.error.message}`)
}
}
}

事前型付けされたcreateAsyncThunkの定義

RTK 1.9以降では、statedispatch、およびextraの型を組み込むことができる、事前型付けされたバージョンのcreateAsyncThunkを定義できます。これにより、これらの型を一度設定するだけで、createAsyncThunkを呼び出すたびに繰り返す必要がなくなります。

これを行うには、createAsyncThunk.withTypes<>()を呼び出し、上記にリストされているAsyncThunkConfig型のフィールド名と型を含むオブジェクトを渡します。これは次のようになります。

const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
rejectValue: string
extra: { s: string; n: number }
}>()

元の代わりに、事前型付けされたcreateAppAsyncThunkをインポートして使用すると、型が自動的に使用されます。

createEntityAdapter

createEntityAdapterの型付けには、エンティティ型を単一のジェネリック引数として指定するだけです。

createEntityAdapterドキュメントの例は、TypeScriptでは次のようになります。

interface Book {
bookId: number
title: string
// ...
}

const booksAdapter = createEntityAdapter<Book>({
selectId: (book) => book.bookId,
sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
bookAdded: booksAdapter.addOne,
booksReceived(state, action: PayloadAction<{ books: Book[] }>) {
booksAdapter.setAll(state, action.payload.books)
},
},
})

normalizrでのcreateEntityAdapterの使用

normalizrのようなライブラリを使用する場合、正規化されたデータは次の形状になります。

{
result: 1,
entities: {
1: { id: 1, other: 'property' },
2: { id: 2, other: 'property' }
}
}

addManyupsertMany、およびsetAllの各メソッドでは、追加の変換ステップなしで、これのentities部分を直接渡すことができます。ただし、normalizrのTS型付けでは、複数のデータ型が結果に含まれる可能性があることを正しく反映していないため、その型構造を自分で指定する必要があります。

その例を次に示します。

type Author = { id: number; name: string }
type Article = { id: number; title: string }
type Comment = { id: number; commenter: number }

export const fetchArticle = createAsyncThunk(
'articles/fetchArticle',
async (id: number) => {
const data = await fakeAPI.articles.show(id)
// Normalize the data so reducers can responded to a predictable payload.
// Note: at the time of writing, normalizr does not automatically infer the result,
// so we explicitly declare the shape of the returned normalized data as a generic arg.
const normalized = normalize<
any,
{
articles: { [key: string]: Article }
users: { [key: string]: Author }
comments: { [key: string]: Comment }
}
>(data, articleEntity)
return normalized.entities
},
)

export const slice = createSlice({
name: 'articles',
initialState: articlesAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchArticle.fulfilled, (state, action) => {
// The type signature on action.payload matches what we passed into the generic for `normalize`, allowing us to access specific properties on `payload.articles` if desired
articlesAdapter.upsertMany(state, action.payload.articles)
})
},
})