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

Reduxエッセンシャルズ Part 5: 非同期処理とデータ取得

非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

学習内容
  • Reduxの「thunk」ミドルウェアを使用して非同期処理を行う方法
  • 非同期リクエストの状態を扱うパターン
  • Redux ToolkitのcreateAsyncThunk APIを使用して非同期呼び出しを管理する方法
前提知識
  • サーバーのREST APIからデータを取得および更新するためのHTTPリクエストの使用に慣れていること

はじめに

Part 4: Reduxデータの使用では、Reactコンポーネント内でReduxストアから複数のデータを使用する方法、アクションオブジェクトの内容をディスパッチ前にカスタマイズする方法、そしてより複雑な更新ロジックをリデューサーで処理する方法を見てきました。

これまでに扱ってきたデータは、すべてReactクライアントアプリケーション内に直接存在していました。しかし、実際のアプリケーションのほとんどは、サーバーからのデータを扱う必要があり、HTTP API呼び出しを行ってアイテムを取得および保存します。

このセクションでは、ソーシャルメディアアプリを改造して、投稿とユーザーのデータをAPIから取得し、新しい投稿をAPIに保存して追加します。

ヒント

Redux ToolkitにはRTK Queryデータ取得およびキャッシュAPIが含まれています。RTK QueryはReduxアプリ向けに特別に構築されたデータ取得およびキャッシュソリューションであり、データ取得を管理するためのthunkやリデューサーなどの追加のReduxロジックを書く必要をなくすことができます。データ取得のデフォルトのアプローチとして、RTK Queryを特に教えます。

RTK Queryはこのページで示すパターンと同じ基盤で構築されているため、このセクションはReduxでのデータ取得がどのように機能するか、その基礎となるメカニズムを理解するのに役立ちます

RTK Queryの使用方法はPart 7: RTK Queryの基礎から始めます。

REST APIとクライアントの例

サンプルプロジェクトを隔離された状態で現実的に保つため、初期プロジェクト設定には、データ用の偽のインメモリREST APIがすでに含まれています(Mock Service WorkerモックAPIツールを使用して設定)。このAPIはエンドポイントのベースURLとして/fakeApiを使用し、/fakeApi/posts/fakeApi/usersfakeApi/notificationsに対して典型的なGET/POST/PUT/DELETE HTTPメソッドをサポートします。これはsrc/api/server.tsで定義されています。

また、プロジェクトには小さなHTTP APIクライアントオブジェクトが含まれており、axiosのような人気のあるHTTPライブラリと同様にclient.get()メソッドとclient.post()メソッドを公開しています。これはsrc/api/client.tsで定義されています。

このセクションでは、インメモリの偽のREST APIに対してHTTP呼び出しを行うためにclientオブジェクトを使用します。

また、モックサーバーはページが読み込まれるたびに同じランダムシードを再利用するように設定されているため、毎回同じ偽のユーザーと偽の投稿のリストが生成されます。これをリセットしたい場合は、ブラウザのLocal Storageにある'randomTimestampSeed'の値を削除してページを再読み込みするか、src/api/server.tsを編集してuseSeededRNGfalseに設定することで無効にできます。

情報

補足として、コード例は各セクションの主要概念と変更点に焦点を当てています。アプリケーションの完全な変更内容については、CodeSandboxプロジェクトとプロジェクトリポジトリのtutorial-steps-tsブランチを参照してください。

非同期処理を可能にするミドルウェアの使用

Reduxストア自体は非同期ロジックについて何も知りません。同期的にアクションをディスパッチし、ルートリデューサー関数を呼び出して状態を更新し、何かが変更されたことをUIに通知することしかできません。非同期処理はストアの外部で行われる必要があります。

しかし、非同期ロジックでアクションをディスパッチしたり、現在のストア状態を確認したり、何らかの副作用を実行したりする必要がある場合はどうでしょうか? そこで活躍するのがReduxミドルウェアです。ミドルウェアはストアの機能を拡張し、次の操作を可能にします:

  • アクションがディスパッチされるたびに追加ロジックを実行(アクションと状態のログ記録など)

  • ディスパッチされたアクションを一時停止、変更、遅延、置換、停止

  • dispatchgetStateにアクセスできる追加コードの記述

  • dispatchがプレーンなアクションオブジェクト以外の値(関数やプロミスなど)を受け入れる方法を教育し、それらをインターセプトして代わりに実際のアクションオブジェクトをディスパッチ

  • 非同期ロジックやその他の副作用を使用するコードの記述

ミドルウェアを使用する最も一般的な理由は、さまざまな種類の非同期ロジックがストアと対話できるようにするためです。これにより、UIから独立したロジックとして、アクションをディスパッチしストア状態を確認するコードを記述できます。

ミドルウェアとReduxストア

ミドルウェアによるReduxストアのカスタマイズ方法の詳細は以下を参照:

ミドルウェアとReduxデータフロー

以前、Reduxの同期データフローの様子を確認しました。

ミドルウェアはdispatchの開始時に追加ステップを設けることでReduxデータフローを更新します。これにより、ミドルウェアはHTTPリクエストなどのロジックを実行した後でアクションをディスパッチできます。非同期データフローは次のようになります:

Redux非同期データフロー図

サンクと非同期ロジック

Redux向けにはさまざまな非同期ミドルウェアが存在し、それぞれ異なる構文でロジックを記述できます。最も一般的な非同期ミドルウェアはredux-thunkで、非同期ロジックを直接含むプレーンな関数を記述できます。Redux ToolkitのconfigureStore関数はデフォルトでサンクミドルウェアを自動設定しReduxでの非同期ロジック記述の標準アプローチとしてサンクの使用を推奨しています

「サンク」とは?

「サンク」という用語は、プログラミングにおいて"遅延作業を行うコード片"を意味します。

Reduxサンクの使用方法の詳細は、サンク使用ガイドページを参照:

また以下の記事も参考になります:

サンク関数

サンクミドルウェアがReduxストアに追加されると、_サンク関数_を直接store.dispatchに渡せるようになります。サンク関数は常に(dispatch, getState)を引数として呼び出され、内部で必要に応じてこれらを使用できます。

サンク関数には_あらゆる_ロジック(同期/非同期問わず)を含められます。

サンクは通常、dispatch(increment())のようにアクションクリエーターを使用してプレーンアクションをディスパッチします:

const store = configureStore({ reducer: counterReducer })

const exampleThunkFunction = (
dispatch: AppDispatch,
getState: () => RootState
) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment())
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}

store.dispatch(exampleThunkFunction)

通常のアクションオブジェクトのディスパッチとの一貫性を保つため、通常は_サンクアクションクリエーター_として記述します。これはサンク関数を返し、サンク内部で使用できる引数を取ることができます。

const logAndAdd = (amount: number) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}

store.dispatch(logAndAdd(5))

Thunk は通常「スライス」ファイル内に記述されます。これは、thunk によるデータ取得が特定のスライスの更新ロジックと概念的に関連しているためです。本セクションでは、thunk を定義するいくつかの異なる方法を見ていきます。

非同期 Thunk の記述

Thunk 内部には setTimeout、Promise、async/await などの非同期ロジックを含めることができます。この特性により、サーバーAPIへのHTTP呼び出しを配置するのに適した場所となります。

Redux におけるデータ取得ロジックは通常、次の予測可能なパターンに従います:

  • リクエスト前に「開始」アクションをディスパッチし、処理中であることを示します。これにより重複リクエストのスキップやUIのローディング表示が可能になります。

  • fetch またはラッパーライブラリを使用した非同期リクエストを実行し、結果のPromiseを取得します

  • リクエストのPromiseが解決すると、非同期ロジックは結果データを含む「成功」アクション、またはエラー詳細を含む「失敗」アクションのいずれかをディスパッチします。Reducerロジックは両ケースでローディング状態を解除し、成功時は結果データを処理し、失敗時は表示用にエラー値を保存します。

これらのステップは_必須ではありません_が、一般的に使用されます(成功結果のみが重要な場合、「開始」と「失敗」アクションをスキップし、リクエスト完了時に単一の「成功」アクションをディスパッチすることも可能です)。

Redux Toolkit は非同期リクエストを記述するアクションの作成とディスパッチを実装するための createAsyncThunk API を提供します

基本的な createAsyncThunk の使用法は次のようになります:

createAsyncThunk example
import { createAsyncThunk } from '@reduxjs/toolkit'

export const fetchItemById = createAsyncThunk(
'items/fetchItemById',
async (itemId: string) => {
const item = await someHttpRequest(itemId)
return item
}
)

非同期リクエストのアクションをディスパッチするコードを createAsyncThunk がどのように簡略化するかについての詳細は、この詳細セクションを参照してください。実際の使用例はすぐ後で見ていきます。

Detailed Explanation: Dispatching Request Status Actions in Thunks

If we were to write out the code for a typical async thunk by hand, it might look like this:

const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = (repoDetails: RepoDetails) => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = (error: any) => ({
type: 'repoDetails/fetchFailed',
error
})

const fetchIssuesCount = (org: string, repo: string) => {
return async (dispatch: AppDispatch) => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}
}

However, writing code using this approach is tedious. Each separate type of request needs repeated similar implementation:

  • Unique action types need to be defined for the three different cases
  • Each of those action types usually has a corresponding action creator function
  • A thunk has to be written that dispatches the correct actions in the right sequence

createAsyncThunk abstracts this pattern by generating the action types and action creators, and generating a thunk that dispatches those actions automatically. You provide a callback function that makes the async call and returns a Promise with the result.

It's also easy to make mistakes with error handling when writing thunk logic yourself. In this case, the try block will actually catch errors from both a failed request, and any errors while dispatching. Handling this correctly would require restructuring the logic to separate those. createAsyncThunk already handles errors correctly for you internally.


Redux Thunks の型付け

Redux Thunk の型付け

手書きで thunk を書いている場合、thunk の引数を (dispatch: AppDispatch, getState: () => RootState) のように明示的に型付けできます。これはよくあることなので、再利用可能な AppThunk 型を定義して、それを使用することもできます:

app/store.ts
import { Action, ThunkAction, configureStore } from '@reduxjs/toolkit'

// omit actual store setup

// Infer the type of `store`
export type AppStore = typeof store
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = typeof store.dispatch
// Same for the `RootState` type
export type RootState = ReturnType<typeof store.getState>
// Export a reusable type for handwritten thunks
export type AppThunk = ThunkAction<void, RootState, unknown, Action>

その後、それを利用して記述中の thunk 関数を説明できます:

Example typed thunk
// Use `AppThunk` as the return type, since we return a thunk function
const logAndAdd = (amount: number): AppThunk => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}

createAsyncThunkの型定義

具体的に createAsyncThunk の場合:ペイロード関数が引数を受け取る場合は、その引数に型を指定してください(例: async (userId: string)。デフォルトでは戻り値の型を指定する必要はありません。TS が自動的に戻り値の型を推論します。

もし createAsyncThunk 内で dispatchgetState にアクセスする必要がある場合、RTK は createAsyncThunk.withTypes() を呼び出すことで、正しい dispatchgetState の型が組み込まれた「事前型付けされた」バージョンを定義する方法を提供します。これは事前型付けされた useSelectoruseDispatch を定義した方法と同様です。新しい src/app/withTypes ファイルを作成し、そこからエクスポートします。

app/withTypes.ts
import { createAsyncThunk } from '@reduxjs/toolkit'

import type { RootState, AppDispatch } from './store'

export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
}>()
Thunkの型付け

TypeScript で thunk を定義する詳細については、次を参照してください:

投稿の読み込み

これまで、私たちの postsSlice は、初期状態としてハードコードされたサンプルデータを使用していました。これを、代わりに空の投稿配列で始まるように変更し、サーバーから投稿のリストを取得します。

これまで postsSlice は初期状態としてハードコードされたサンプルデータを使用していました。これを空の投稿配列で開始するように変更し、サーバーから投稿リストを取得します。

リクエストの読み込み状態

API呼び出しを行う際、その進捗状況は次の4つの状態のいずれかを取る小さなステートマシンとして捉えることができます:

  • リクエストがまだ開始されていない

  • リクエストが進行中である

  • リクエストが成功し、必要なデータを取得した

  • リクエストが失敗し、おそらくエラーメッセージがある

この情報をisLoading: trueのようなブール値で追跡することも可能ですが、これらの状態を単一の共用体型(union type)の値として管理する方が優れた方法です。推奨パターンとしては、次のような状態構造を採用します(TypeScriptの文字列共用体型表記を使用):

{
// Multiple possible status string union values
status: 'idle' | 'pending' | 'succeeded' | 'failed',
error: string | null
}

これらのフィールドは、実際に保存されるデータと併せて存在します。これらの具体的な状態名は必須ではありません - 必要に応じて'pending'の代わりに'loading'を、'succeeded'の代わりに'completed'を使用するなど自由に変更可能です。

この情報を活用して、リクエストの進行状況に応じてUIに表示する内容を決定できます。また、リデューサーにロジックを追加してデータの二重読み込みを防止することも可能です。

投稿取得リクエストのローディング状態を管理するため、postsSliceを更新しましょう。状態を単なる投稿配列から{posts, status, error}形式に変更します。初期状態から古いサンプル投稿を削除し、ローディング状態とエラー状態を取得する新しいセレクターを追加します:

features/posts/postsSlice.ts
import { createSlice, nanoid } from '@reduxjs/toolkit'

// omit reactions and other types

interface PostsState {
posts: Post[]
status: 'idle' | 'pending' | 'succeeded' | 'failed'
error: string | null
}

const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.posts.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
// omit prepare logic
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
}
})

export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions

export default postsSlice.reducer


export const selectAllPosts = (state: RootState) => state.posts.posts

export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)

export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error

この変更に伴い、配列として扱っていたstateの参照箇所はすべてstate.postsに変更する必要があります(配列が一段階深くなったため)。

これによりstate.posts.postsのような若干冗長で奇妙なネスト構造が生じますが、現時点ではこのままにしておきます。ネストされた配列名をitemsdataなどに変更することも可能ですが、今回はそのまま維持します。

createAsyncThunkを使ったデータ取得

Redux ToolkitのcreateAsyncThunk APIは、「開始/成功/失敗」アクションを自動的にディスパッチするサンクを生成します。

まず、投稿リストを取得するHTTPリクエストを行うサンクを追加します。src/apiフォルダからclientユーティリティをインポートし、'/fakeApi/posts'へのリクエストに使用します。

features/posts/postsSlice.ts
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'

import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'

// omit other imports and types

export const fetchPosts = createAppAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})

const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}

createAsyncThunkは2つの引数を受け取ります:

  • 生成されるアクションタイプのプレフィックスとして使用される文字列

  • 何らかのデータを含むPromise、またはエラーを含む拒否されたPromiseを返す「ペイロードクリエーター」コールバック関数

ペイロードクリエーターは通常HTTPリクエストを行い、HTTPリクエストから直接Promiseを返すか、APIレスポンスからデータを抽出して返します。JSのasync/await構文を使用して記述するのが一般的で、これによりsomePromise.then()チェーンではなく標準的なtry/catchロジックを使ったPromise処理が可能になります。

今回の場合、アクションタイプのプレフィックスとして'posts/fetchPosts'を渡します。

fetchPostsのペイロード作成コールバックは引数を必要とせず、API呼び出しがレスポンスを返すのを待つだけです。レスポンスオブジェクトは{data: []}形式で、ディスパッチされるReduxアクションのペイロードは投稿配列そのものにしたいため、response.dataを抽出してコールバックから返します。

dispatch(fetchPosts())を呼び出すと、fetchPostsサンクは最初に'posts/fetchPosts/pending'タイプのアクションをディスパッチします:

createAsyncThunk: posts pending action

リデューサーでこのアクションをリッスンし、リクエスト状態を'pending'に設定できます。

Promiseが解決すると、fetchPostsサンクはコールバックから返されたresponse.data配列を受け取り、投稿配列をaction.payloadに含んだ'posts/fetchPosts/fulfilled'アクションをディスパッチします:

createAsyncThunk: 投稿保留アクション

リデューサーとロードアクション

次に、リデューサー内でこれらのアクションを両方処理する必要があります。これには、これまで使用してきたcreateSlice APIをさらに深く理解する必要があります。

createSlicereducersフィールドで定義した各リデューサー関数に対してアクションクリエーターを生成することは既に見てきました。生成されるアクションタイプにはスライス名が含まれます。例:

console.log(
postUpdated({ id: '123', title: 'First Post', content: 'Some text here' })
)
/*
{
type: 'posts/postUpdated',
payload: {
id: '123',
title: 'First Post',
content: 'Some text here'
}
}
*/

また、スライス外部で定義されたアクションに応答するためにcreateSliceextraReducersフィールドを使用できることも既に確認しています。

今回のケースでは、fetchPostsサンクがディスパッチする「pending」と「fulfilled」アクションタイプをリッスンする必要があります。これらのアクションクリエーターは実際のfetchPost関数に付属しており、extraReducersに渡してアクションを監視できます:

features/posts/postsSlice.ts
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},

extraReducers: builder => {
builder
.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'pending'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? 'Unknown Error'
})
}
})

返したPromiseに基づき、サンクがディスパッチする可能性のある3つのアクションタイプをすべて処理します:

  • リクエスト開始時には status'pending' に設定します

  • リクエスト成功時、status'succeeded'にマークし、取得した投稿をstate.postsに追加

  • リクエスト失敗時、status'failed'にマークし、エラーメッセージを状態に保存して表示可能に

コンポーネントからのサンクディスパッチ

fetchPostsサンクを作成し、それらのアクションを処理するようにスライスを更新したので、実際にデータ取得を開始するために<PostsList>コンポーネントを更新しましょう。

コンポーネントにfetchPostsサンクをインポートします。他のアクションクリエーターと同様、ディスパッチする必要があるためuseAppDispatchフックも追加します。<PostsList>マウント時にデータを取得したいため、ReactのuseEffectフックをインポートし、アクションをディスパッチします。

投稿リストの取得は一度だけ行うことが重要です。<PostsList>コンポーネントがレンダリングされるたび、またはビュー切り替えでコンポーネントが再作成されるたびに取得を実行すると、複数回の取得が発生する可能性があります。この問題を回避するには、コンポーネント内でposts.statusの値を選択し、ステータスが'idle'(未開始状態)の場合のみ実際の取得を開始するようにします。

features/posts/PostsList.tsx
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'

import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'

import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import { fetchPosts, selectAllPosts, selectPostsStatus } from './postsSlice'

export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)

useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])

// omit rendering logic
}

これで、アプリにログインした後に新しい投稿リストが表示されるはずです!

取得した投稿リスト

重複取得の防止

良いニュース:モックサーバーAPIから投稿オブジェクトを正常に取得できました。

残念ながら問題が発生しています。現在、投稿リストに各投稿の重複が表示されています:

重複投稿アイテム

実際、Redux DevToolsを見ると、'pending''fulfilled'アクションが2セットディスパッチされているのがわかります:

重複fetchPostsアクション

なぜでしょうか?postStatus === 'idle'のチェックを追加したばかりではありませんか?これでサンクを1回だけディスパッチできるはずでは?

そうですね... でもそうでもないのです :)

ここでの useEffect 内のロジック自体は正しいものです。問題は、現在開発ビルドのアプリケーションを見ていることであり、開発環境では React は <StrictMode> コンポーネント内でマウント時にすべての useEffect フックを2回実行します。これは特定の種類のバグを顕在化させるためです。

この場合、以下の流れが発生しました:

  • <PostsList> コンポーネントがマウントされた

  • useEffect フックが初めて実行され、postStatus'idle' だったため fetchPosts サンクがディスパッチされた

  • fetchPosts が即座に fetchPosts.pending アクションをディスパッチしたため、Redux ストアはステータスを 'pending' に更新

  • しかし React がコンポーネントの再レンダリングなしに useEffect を再実行したため、エフェクトは postStatus がまだ 'idle' だと判断し fetchPosts を再度ディスパッチ

  • 両方のthunkがデータの取得を完了し、fetchPosts.fulfilled アクションをディスパッチします。その結果、fulfilled リデューサーが2回実行され、重複した投稿がstateに追加されます

では、どのように修正すればよいでしょうか?

選択肢の一つはアプリから <StrictMode> タグを削除することですが、React チームはこれを推奨しており他の問題検出に役立ちます。

useRef フックで初回レンダリングを追跡する複雑なロジックを書いて fetchPosts を一度だけディスパッチする方法もありますが、これは洗練されていません。

最後の選択肢は Redux ステートの state.posts.status 値を実際にチェックし、リクエスト進行中ならサンク自体に処理を中止させることです。幸い createAsyncThunk にはこれを実現する方法があります。

非同期サンクの条件チェック

createAsyncThunk はオプションの condition コールバックを受け付けます。指定するとサンク呼び出し開始時に実行され、conditionfalse. を返すとサンク全体がキャンセルされます。

このケースでは state.posts.status'idle' でない場合のサンク実行を避けたいわけです。既存の selectPostsStatus セレクターを利用できるので、condition オプションを追加して値をチェックします:

export const fetchPosts = createAppAsyncThunk(
'posts/fetchPosts',
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
condition(arg, thunkApi) {
const postsStatus = selectPostsStatus(thunkApi.getState())
if (postsStatus !== 'idle') {
return false
}
}
}
)

これでページをリロードして <PostsList> を見ると、重複のない投稿セットが1つだけ表示され、Redux DevTools でもディスパッチされたアクションが1セットのみ確認できるはずです。

すべてのサンクに condition を追加する必要はありませんが、同時に複数リクエストが発生しないようにする場合に有用です。

ヒント

RTK Query がこれを自動管理します! すべてのコンポーネント間でリクエストを重複排除するため、各リクエストは一度だけ実行され、手動で対応する必要がなくなります。

ローディング状態の表示

<PostsList> コンポーネントは Redux に保存された投稿の更新をチェックし、リスト変更時に自身を再レンダリングします。ページを更新するとフェイク API からランダムな投稿セットが表示されますが、若干の遅延が発生します — <PostsList> は最初空で、数秒後に投稿が表示されます。

実際の API 呼び出しは応答に時間がかかるため、通常は UI に「読み込み中...」インジケーターを表示し、ユーザーにデータ待機中であることを伝えるのが良いプラクティスです。

<PostsList>コンポーネントを更新し、state.posts.statusの値に基づいて異なるUIを表示できます:読み込み中はスピナー、失敗時はエラーメッセージ、データ取得後は実際の投稿リストを表示します。

ついでに、リスト内の各アイテムのレンダリングをカプセル化するため <PostExcerpt> コンポーネントを抽出する良いタイミングでしょう。

結果は次のようになります:

features/posts/PostsList.tsx
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'

import { useAppSelector, useAppDispatch } from '@/app/hooks'

import { Spinner } from '@/components/Spinner'
import { TimeAgo } from '@/components/TimeAgo'

import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import {
Post,
selectAllPosts,
selectPostsError,
fetchPosts
} from './postsSlice'

interface PostExcerptProps {
post: Post
}

function PostExcerpt({ post }: PostExcerptProps) {
return (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<ReactionButtons post={post} />
</article>
)
}

export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)
const postsError = useAppSelector(selectPostsError)

useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])

let content: React.ReactNode

if (postStatus === 'pending') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts
.slice()
.sort((a, b) => b.date.localeCompare(a.date))

content = orderedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}

return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}

API呼び出しに時間がかかり、ローディングスピナーが数秒間表示され続けることに気付くかもしれません。モックAPIサーバーはすべてのレスポンスに2秒の遅延を追加するよう設定されており、ローディングスピナーが表示される時間を可視化するため特別に設計されています。この動作を変更したい場合は、api/server.ts を開き、次の行を修正してください:

api/server.ts
// Add an extra delay to all endpoints, so loading spinners show up.
const ARTIFICIAL_DELAY_MS = 2000

API呼び出しをより速く完了させたい場合は、必要に応じてこの設定をオン/オフしてください。

オプション: createSlice 内でのthunk定義

現在、fetchPosts thunkは postsSlice.ts ファイル内で定義されていますが、createSlice() 呼び出しの外側にあります。

thunkを createSlice 内で定義するオプションの方法もあり、これには reducers フィールドの定義方法を変更する必要があります。詳細は以下の説明を参照してください:

Defining Thunks in createSlice

We've seen that the standard way to write the createSlice.reducers field is as an object, where the keys become the action names, and the values are reducers. We also saw that the values can be an object with the {reducer, prepare} functions for creating an action object with the values we want.

Alternately, the reducers field can be a callback function that receives a create object. This is somewhat similar to what we saw with extraReducers, but with a different set of methods for creating reducers and actions:

  • create.reducer<PayloadType>(caseReducer): defines a case reducer
  • create.preparedReducer(prepare, caseReducer): defines a reducer with a prepare callback

Then, return an object like before with the reducer names as the fields, but call the create methods to make each reducer. Here's what the postsSlice would look like converted to this syntax:

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: create => {
return {
postAdded: create.preparedReducer(
(title: string, content: string, userId: string) => {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
reactions: initialReactions
}
}
},
(state, action) => {
state.posts.push(action.payload)
}
),
postUpdated: create.reducer<PostUpdate>((state, action) => {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}),
reactionAdded: create.reducer<{ postId: string; reaction: ReactionName }>(
(state, action) => {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
)
}
},
extraReducers: builder => {
// same as before
}
})

Writing reducers as a callback opens the door for extending the capabilities of createSlice. In particular, it's possible to make a special version of createSlice that has the ability to use createAsyncThunk baked in.

First, import buildCreateSlice and asyncThunkCreator, then call buildCreateSlice like this:

import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit'

export const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator }
})

That gives you a version of createSlice with the ability to write thunks inside.

Finally, we can use that createAppSlice method to define our postsSlice with the fetchPosts thunk inside. When we do that, a couple other things change:

  • We can't pass in the RootState generic directly, so we have to do getState() as RootState to cast it
  • We can pass in all of the reducers that handle the thunk actions as part of the options to create.asyncThunk(), and remove those from the extraReducers field:
const postsSlice = createAppSlice({
name: 'posts',
initialState,
reducers: create => {
return {
// omit the other reducers
fetchPosts: create.asyncThunk(
// Payload creator function to fetch the data
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
// Options for `createAsyncThunk`
options: {
condition(arg, thunkApi) {
const { posts } = thunkApi.getState() as RootState
if (posts.status !== 'idle') {
return false
}
}
},
// The case reducers to handle the dispatched actions.
// Each of these is optional, but must use these names.
pending: (state, action) => {
state.status = 'pending'
},
fulfilled: (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
},
rejected: (state, action) => {
state.status = 'rejected'
state.error = action.error.message ?? 'Unknown Error'
}
}
)
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// The thunk handlers have been removed here
}
})

Remember, the create callback syntax is optional! The only time you have to use it is if you really want to write thunks inside of createSlice. That said, it does remove the need to use the PayloadAction type, and cuts down on extraReducers as well.

ユーザーの読み込み

これで投稿リストの取得と表示が可能になりました。しかし、投稿を見ると問題があります:すべての投稿の著者が「Unknown author」と表示されています:

投稿者不明

これは、投稿エントリが偽のAPIサーバーによってランダム生成され、ページをリロードするたびに偽のユーザーセットもランダム生成されるためです。アプリケーション起動時にこれらのユーザーを取得するため、ユーザースライスを更新する必要があります。

前回と同様に、APIからユーザーを取得して返す非同期thunkを作成し、extraReducers スライスフィールドで fulfilled アクションを処理します。今回はローディング状態の処理は省略します:

features/users/usersSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

import { client } from '@/api/client'

import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'

interface User {
id: string
name: string
}

export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get<User[]>('/fakeApi/users')
return response.data
})

const initialState: User[] = []

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload
})
}
})

export default usersSlice.reducer

// omit selectors

今回のケースリデューサーが state 変数を全く使用していないことに気付くかもしれません。代わりに、直接 action.payload を返しています。Immerでは、既存のstate値を変更(mutate)する方法と、新しい結果を返す(return)方法の2つでstateを更新できます。新しい値を返す場合、返した値で既存のstateを完全に置き換えます(なお、手動で新しい値を返す場合、必要な不変性更新ロジックを自分で記述する必要があります)。

初期状態は空の配列でしたが、state.push(...action.payload) で変更することもできました。しかし今回は、サーバーから返された内容でユーザーリストを完全に置き換えたいため、この方法を採用しています。これにより、state内でユーザーリストが重複する可能性を回避できます。

情報

Immerを使ったstate更新の詳細については、RTKドキュメントの「Immerを使ったreducerの作成」ガイドを参照してください。

ユーザーリストの取得は一度だけ必要であり、アプリケーション起動直後に実行したいものです。これは main.tsx ファイルで実装でき、store が直接利用可能なため fetchUsers thunkを直接ディスパッチします:

main.tsx
// omit other imports

import store from './app/store'
import { fetchUsers } from './features/users/usersSlice'

import { worker } from './api/server'

async function start() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })

store.dispatch(fetchUsers())

const root = createRoot(document.getElementById('root')!)

root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}

start()

これは起動時のデータ取得方法として有効です。この方法ではReactコンポーネントのレンダリング前に取得プロセスが開始されるため、データをより早く利用可能にできます(この原則はReact Routerのデータローダーを使用しても適用できます)。

これで、各投稿に再度ユーザー名が表示されるようになり、<AddPostForm> の「Author」ドロップダウンにも同じユーザーリストが表示されるはずです。

新しい投稿の追加

このセクションにはあと1ステップあります。<AddPostForm>から新しい投稿を追加すると、現在はアプリ内のReduxストアにのみ追加されています。実際にフェイクAPIサーバーで新しい投稿エントリを作成するAPI呼び出しを行う必要があり、これによってデータが「保存」されます(これはフェイクAPIのため、ページをリロードしても新しい投稿は持続しませんが、実際のバックエンドサーバーがあれば次回リロード時に利用可能になります)。

データの送信とThunk

createAsyncThunkはデータ取得だけでなく送信にも使用できます。<AddPostForm>からの値を受け取るthunkを作成し、データを保存するためにフェイクAPIへHTTP POSTリクエストを送信します。

この過程で、レデューサー内の新しい投稿オブジェクトの扱い方を変更します。現在、postsSlicepostAddedprepareコールバックで新しい投稿オブジェクトを作成し、一意のIDを生成しています。データをサーバーに保存するほとんどのアプリでは、サーバーが一意のID生成と追加フィールドの埋め込みを行い、通常はレスポンスで完成したデータを返します。そのため、{ title, content, user: userId }のようなリクエストボディをサーバーに送信し、返ってきた完成した投稿オブジェクトをpostsSliceの状態に追加できます。また、thunkに渡されるオブジェクトを表すNewPost型を抽出します。

features/posts/postsSlice.ts
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
type NewPost = Pick<Post, 'title' | 'content' | 'user'>

export const addNewPost = createAppAsyncThunk(
'posts/addNewPost',
// The payload creator receives the partial `{title, content, user}` object
async (initialPost: NewPost) => {
// We send the initial data to the fake API server
const response = await client.post<Post>('/fakeApi/posts', initialPost)
// The response includes the complete post object, including unique ID
return response.data
}
)

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// The existing `postAdded` reducer and prepare callback were deleted
reactionAdded(state, action) {}, // omit logic
postUpdated(state, action) {} // omit logic
},
extraReducers(builder) {
builder
// omit the cases for `fetchPosts` and `userLoggedOut`
.addCase(addNewPost.fulfilled, (state, action) => {
// We can directly add the new post object to our posts array
state.posts.push(action.payload)
})
}
})

// Remove `postAdded`
export const { postUpdated, reactionAdded } = postsSlice.actions

コンポーネントでのThunk結果の確認

最後に、<AddPostForm>を更新して古いpostAddedアクションの代わりにaddNewPost thunkをディスパッチします。これもサーバーへのAPI呼び出しであるため、時間がかかり失敗する可能性があります。addNewPost() thunkは自動的にpending/fulfilled/rejectedアクションをReduxストアにディスパッチし、これは既に処理済みです。

必要に応じて、postsSlice内で2つ目のローディング状態共用体型を使用してリクエストステータスを追跡することも可能です。ただしこの例では、他の実装方法の可能性を示すため、ローディング状態の追跡をコンポーネントに限定します。

少なくともリクエスト待機中に「投稿を保存」ボタンを無効化し、ユーザーが誤って二重投稿するのを防ぐのが良いでしょう。リクエストが失敗した場合は、フォームにエラーメッセージを表示するか、コンソールに記録することも考えられます。

コンポーネントロジックで非同期thunkの完了を待機し、終了時に結果を確認できます:

features/posts/AddPostForm.tsx
import React, { useState } from 'react'

import { useAppDispatch, useAppSelector } from '@/app/hooks'

import { selectCurrentUsername } from '@/features/auth/authSlice'

import { addNewPost } from './postsSlice'

// omit field types

export const AddPostForm = () => {
const [addRequestStatus, setAddRequestStatus] = useState<'idle' | 'pending'>(
'idle'
)

const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!

const handleSubmit = async (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

const form = e.currentTarget

try {
setAddRequestStatus('pending')
await dispatch(addNewPost({ title, content, user: userId })).unwrap()

form.reset()
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}

// omit rendering logic
}

ReactのuseStateフックを使用してローディングステータスを追加できます。これは投稿取得時にpostsSliceでローディング状態を追跡する方法と類似しており、このケースではリクエストが進行中かどうかのみを追跡します。

dispatch(addNewPost())を呼び出すと、非同期thunkはdispatchからPromiseを返します。このPromiseをawaitすることでthunkのリクエスト完了を検知できます。ただし、リクエストが成功したか失敗したかはまだわかりません。

createAsyncThunkは内部でエラーを処理するため、ログに「rejected Promise」メッセージは表示されません。そして最終的にディスパッチされたアクション(成功時はfulfilled、失敗時はrejected)を返します。つまり**await dispatch(someAsyncThunk())は常に「成功」し、結果はアクションオブジェクト自体となります**。

しかし、実際に行われたリクエストの成功/失敗を確認するロジックを書きたい場合が一般的です。Redux Toolkitは返されたPromiseに.unwrap()関数を追加しており、これはfulfilledアクションの実際のaction.payload値を持つ新しいPromiseを返すか、rejectedアクションの場合はエラーをスローします。これにより、コンポーネント内で通常のtry/catchロジックを使用して成功と失敗を処理できます。投稿が正常に作成された場合は入力フィールドをクリアしてフォームをリセットし、失敗した場合はエラーをコンソールに記録します。

addNewPost API呼び出しが失敗した場合の挙動を確認したい場合は、「Content」フィールドに「error」という単語のみ(引用符なし)を入力して新しい投稿を作成してみてください。サーバーがこれを検知して失敗レスポンスを返すため、コンソールにメッセージが記録されます。

学んだこと

非同期処理とデータ取得は常に複雑なトピックです。Redux Toolkitには、典型的なReduxデータ取得パターンを自動化するツールが含まれています。

偽装APIからデータを取得した現在のアプリケーションの外観は次の通りです:

本セクションで学んだ内容を改めてまとめます:

まとめ
  • Reduxは非同期処理を可能にする「ミドルウェア」プラグインを使用
    • 標準的な非同期ミドルウェアはredux-thunkと呼ばれ、Redux Toolkitに含まれる
    • Thunk関数は引数としてdispatchgetStateを受け取り、非同期処理の一部として使用可能
  • API呼び出しのローディング状態を追跡するために追加アクションをディスパッチ可能
    • 典型的なパターン:呼び出し前に「pending」アクションをディスパッチし、データを含む「success」またはエラーを含む「failure」アクションを後続
    • ローディング状態は通常、'idle' | 'pending' | 'succeeded' | 'rejected'のような文字列リテラルのユニオン型で保持
  • Redux Toolkitはこれらのアクションを自動ディスパッチするcreateAsyncThunk APIを提供
    • createAsyncThunkはPromiseを返す「ペイロードクリエーター」コールバックを受け入れ、pending/fulfilled/rejectedアクションタイプを自動生成
    • fetchPostsのような生成されたアクションクリエーターは、返されたPromiseに基づいてこれらのアクションをディスパッチ
    • createSliceextraReducersフィールドでこれらのアクションタイプをリッスンし、対応するリデューサーで状態を更新可能
    • createAsyncThunkconditionオプションでRedux状態に基づくリクエストキャンセルが可能
    • ThunkはPromiseを返却可能。特にcreateAsyncThunkでは、await dispatch(someThunk()).unwrap()でコンポーネントレベルでのリクエスト成功/失敗処理が可能

次のステップ

Redux ToolkitのコアAPIと使用パターンに関する最後のトピックがあります。パート6:パフォーマンスとデータ正規化では、Reduxの使用がReactパフォーマンスに与える影響と、アプリケーションのパフォーマンスを最適化する方法について解説します。