Redux Essentials, パート 5: 非同期ロジックとデータフェッチ
- 非同期ロジックに Redux "thunk" ミドルウェアを使用する方法
- 非同期リクエストの状態を処理するパターン
- Redux Toolkit の
createAsyncThunk
API を使用して非同期呼び出しを簡略化する方法
- AJAXリクエストを使用してサーバーからデータをフェッチおよび更新することに慣れていること
はじめに
パート 4: Redux データの使用では、React コンポーネント内で Redux ストアから複数のデータを使用する方法、ディスパッチされる前にアクションオブジェクトの内容をカスタマイズする方法、およびリデューサーでより複雑な更新ロジックを処理する方法について説明しました。
これまでのところ、私たちが使用してきたすべてのデータは、React クライアントアプリケーション内に直接ありました。しかし、ほとんどの実際のアプリケーションでは、HTTP API呼び出しを行ってアイテムをフェッチおよび保存することにより、サーバーからのデータを操作する必要があります。
このセクションでは、ソーシャルメディアアプリを、APIから投稿とユーザーのデータをフェッチし、APIに保存して新しい投稿を追加するように変換します。
Redux Toolkit には、RTK Query データフェッチとキャッシュ APIが含まれています。RTK Query は、Redux アプリ用に構築された専用のデータフェッチおよびキャッシングソリューションであり、データフェッチを管理するためのサンクまたはリデューサーをまったく記述する必要をなくすことができます。特に、RTK Query をデータフェッチのデフォルトのアプローチとして教え、RTK Query はこのページで示すのと同じパターンに基づいて構築されています。
パート 7: RTK Query の基本から RTK Query の使用方法について説明します。
REST API とクライアントの例
例題プロジェクトを隔離された状態に保ちながらも現実的なものにするため、最初のプロジェクト設定には、データのフェイクインメモリ REST API がすでに含まれています(Mock Service Worker モック API ツールを使用して構成されています)。API は、エンドポイントのベース URL として /fakeApi
を使用し、/fakeApi/posts
、/fakeApi/users
、および fakeApi/notifications
に対して典型的な GET/POST/PUT/DELETE
HTTP メソッドをサポートします。これは src/api/server.js
で定義されています。
また、このプロジェクトには、一般的な HTTP ライブラリ(axios
など)と同様の client.get()
および client.post()
メソッドを公開する小さな HTTP API クライアントオブジェクトも含まれています。これは src/api/client.js
で定義されています。
このセクションでは、client
オブジェクトを使用して、インメモリのフェイク REST API に対して HTTP 呼び出しを行います。
また、モックサーバーは、ページが読み込まれるたびに同じ乱数シードを再利用するように設定されているため、同じフェイクユーザーとフェイク投稿のリストが生成されます。リセットする場合は、ブラウザのローカルストレージにある 'randomTimestampSeed'
値を削除してページをリロードするか、src/api/server.js
を編集して useSeededRNG
を false
に設定して無効にできます。
念のため、コード例は各セクションの主要な概念と変更に焦点を当てています。アプリケーションの完全な変更については、CodeSandbox プロジェクトと、プロジェクトリポジトリの tutorial-steps
ブランチを参照してください。
サンクと非同期ロジック
ミドルウェアを使用して非同期ロジックを有効にする
Redux ストア自体は、非同期ロジックについて何も知りません。アクションを同期的にディスパッチし、ルートリデューサー関数を呼び出して状態を更新し、何かが変更されたことを UI に通知する方法のみを認識しています。非同期性はストアの外部で発生する必要があります。
しかし、非同期ロジックが、ディスパッチまたは現在のストアの状態を確認することによって、ストアと対話したい場合はどうでしょうか?そこでRedux ミドルウェアが登場します。これらはストアを拡張し、次のことを可能にします。
- アクションがディスパッチされたときに追加のロジックを実行する(アクションと状態のロギングなど)
- ディスパッチされたアクションを一時停止、変更、遅延、置換、または停止する
dispatch
およびgetState
にアクセスできる追加のコードを記述する- 実際のオブジェクトをディスパッチする代わりに、関数やプロミスなどのプレーンなアクションオブジェクト以外の値を受け入れる方法を
dispatch
に教える(それらをインターセプトし、代わりに実際のアクションオブジェクトをディスパッチすることによって)
ミドルウェアを使用する最も一般的な理由は、さまざまな種類の非同期ロジックがストアと対話できるようにすることです。これにより、UI からロジックを分離しながら、アクションをディスパッチしてストアの状態を確認できるコードを作成できます。
Redux にはさまざまな種類の非同期ミドルウェアがあり、それぞれが異なる構文を使用してロジックを記述できます。最も一般的な非同期ミドルウェアは redux-thunk
であり、非同期ロジックを直接含むことができるプレーン関数を記述できます。Redux Toolkit の configureStore
関数は、デフォルトでサンクミドルウェアを自動的に設定し、Redux で非同期ロジックを記述するための標準的なアプローチとしてサンクを使用することをお勧めします。
以前、Redux の同期データフローがどのようなものかを見てきました。非同期ロジックを導入すると、ミドルウェアが AJAX リクエストなどのロジックを実行し、アクションをディスパッチできる追加のステップが追加されます。これにより、非同期データフローは次のようになります。
サンク関数
サンクミドルウェアが Redux ストアに追加されると、サンク関数を store.dispatch
に直接渡せるようになります。サンク関数は常に (dispatch, getState)
を引数として呼び出され、必要に応じてサンク内で使用できます。
サンクは通常、dispatch(increment())
のように、アクションクリエーターを使用してプレーンアクションをディスパッチします。
const store = configureStore({ reducer: counterReducer })
const exampleThunkFunction = (dispatch, getState) => {
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 => {
return (dispatch, getState) => {
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))
サンクは通常、「スライス」ファイルに記述されます。createSlice
自体にはサンクを定義するための特別なサポートがないため、同じスライスファイルに個別の関数として記述する必要があります。そうすれば、そのスライスのプレーンアクションクリエーターにアクセスでき、サンクがどこにあるかを簡単に見つけることができます。
「サンク」という単語は、「遅延作業を行うコードの一部」を意味するプログラミング用語です。サンクの使用方法の詳細については、サンクの使用ガイドのページを参照してください。
これらの投稿も参照してください。
非同期サンクの記述
サンクは、setTimeout
、Promise
、async/await
などの非同期ロジックを内部に持つことができます。これにより、サーバーAPIへのAJAX呼び出しを配置するのに適した場所となります。
Reduxのデータ取得ロジックは、通常、予測可能なパターンに従います。
- リクエストが進行中であることを示すために、リクエストの前に「開始」アクションがディスパッチされます。これは、重複リクエストをスキップしたり、UIにローディングインジケーターを表示するために、ローディング状態を追跡するために使用できます。
- 非同期リクエストが実行されます。
- リクエストの結果に応じて、非同期ロジックは、結果データを含む「成功」アクション、またはエラー詳細を含む「失敗」アクションのいずれかをディスパッチします。リデューサーロジックは、両方の場合でローディング状態をクリアし、成功の場合は結果データを処理し、失敗の場合は表示のためのエラー値を保存します。
これらの手順は必須ではありませんが、一般的に使用されます。(成功した結果のみに関心がある場合は、リクエストが完了したときに単一の「成功」アクションをディスパッチし、「開始」および「失敗」アクションをスキップできます。)
Redux Toolkitは、これらのアクションの作成とディスパッチを実装するためのcreateAsyncThunk
APIを提供しており、その使用方法をすぐに確認します。
詳細な説明:サンクでのリクエストステータスアクションのディスパッチ
典型的な非同期サンクのコードを手作業で記述すると、次のようになる可能性があります。
const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = repoDetails => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = error => ({
type: 'repoDetails/fetchFailed',
error
})
const fetchIssuesCount = (org, repo) => async dispatch => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}
ただし、このアプローチを使用してコードを記述するのは面倒です。リクエストの種類ごとに、同様の実装を繰り返す必要があります。
- 3つの異なるケースに対して、一意のアクションタイプを定義する必要があります。
- これらのアクションタイプには、通常、対応するアクションクリエーター関数があります。
- サンクは、正しい順序で正しいアクションをディスパッチするように記述する必要があります。
createAsyncThunk
は、アクションタイプとアクションクリエーターを生成し、これらのアクションを自動的にディスパッチするサンクを生成することで、このパターンを抽象化します。非同期呼び出しを行い、結果を含むPromiseを返すコールバック関数を指定します。
投稿の読み込み
これまでのところ、postsSlice
は初期状態としてハードコードされたサンプルデータを使用しています。これを、代わりに投稿の空の配列から開始するように変更し、サーバーから投稿のリストを取得します。
これを行うには、APIリクエストの現在の状態を追跡できるように、postsSlice
の状態の構造を変更する必要があります。
投稿セレクターの抽出
現在、postsSlice
の状態はposts
の単一の配列です。これを、posts
配列とローディング状態フィールドを持つオブジェクトに変更する必要があります。
一方、<PostsList>
などのUIコンポーネントは、useSelector
フックでstate.posts
から投稿を読み取ろうとしており、そのフィールドが配列であると想定しています。これらの場所も新しいデータに合わせて変更する必要があります。
リデューサーのデータ形式を変更するたびに、コンポーネントを書き換える必要がないのが望ましいです。これを回避する方法の1つは、スライスファイルで再利用可能なセレクター関数を定義し、コンポーネントが各コンポーネントでセレクターロジックを繰り返すのではなく、これらのセレクターを使用して必要なデータを抽出するようにすることです。そうすることで、状態構造を再度変更する場合でも、スライスファイル内のコードのみを更新する必要があります。
<PostsList>
コンポーネントはすべての投稿のリストを読み取る必要があり、<SinglePostPage>
および<EditPostForm>
コンポーネントはIDで単一の投稿を検索する必要があります。これらのケースに対応するために、postsSlice.js
から2つの小さなセレクター関数をエクスポートしましょう。
const postsSlice = createSlice(/* omit slice code*/)
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = state => state.posts
export const selectPostById = (state, postId) =>
state.posts.find(post => post.id === postId)
これらのセレクター関数のstate
パラメーターは、useSelector
の内部に直接記述したインラインの匿名セレクターの場合と同様に、ルートRedux状態オブジェクトです。
それらをコンポーネントで使用できます。
// omit imports
import { selectAllPosts } from './postsSlice'
export const PostsList = () => {
const posts = useSelector(selectAllPosts)
// omit component contents
}
// omit imports
import { selectPostById } from './postsSlice'
export const SinglePostPage = ({ match }) => {
const { postId } = match.params
const post = useSelector(state => selectPostById(state, postId))
// omit component logic
}
// omit imports
import { postUpdated, selectPostById } from './postsSlice'
export const EditPostForm = ({ match }) => {
const { postId } = match.params
const post = useSelector(state => selectPostById(state, postId))
// omit component logic
}
再利用可能なセレクターを記述してデータ検索をカプセル化するのは良いアイデアです。パフォーマンスを向上させるのに役立つ「メモ化された」セレクターを作成することもできます。これについては、このチュートリアルの後半で説明します。
しかし、他の抽象化と同様に、常に、どこでも行うべきではありません。セレクターを記述するということは、理解して保守するコードが増えることを意味します。**状態のすべてのフィールドに対してセレクターを記述する必要があるとは感じないでください。**セレクターなしで開始し、アプリケーションコードの多くの部分で同じ値を検索していることに気付いたときに後で追加してみてください。
リクエストのローディング状態
API呼び出しを行うとき、その進行状況を4つの可能な状態のいずれかにある小さな状態マシンとして見ることができます。
- リクエストはまだ開始されていません。
- リクエストが進行中です。
- リクエストは成功し、必要なデータが得られました。
- リクエストは失敗し、おそらくエラーメッセージがあります。
isLoading: true
のようなブール値を使用してその情報を追跡することもできますが、これらの状態を単一のenum値として追跡する方が良いでしょう。これに適したパターンは、次のような状態セクションを持つことです(TypeScriptの型表記を使用)。
{
// Multiple possible status enum values
status: 'idle' | 'loading' | 'succeeded' | 'failed',
error: string | null
}
これらのフィールドは、保存されている実際のデータとともに存在します。これらの特定の文字列状態名は必須ではありません。'loading'
の代わりに'pending'
、または'succeeded'
の代わりに'complete'
など、必要に応じて他の名前を使用してください。
この情報を使用して、リクエストの進行中にUIに表示するものを決定したり、リデューサーにロジックを追加して、データを2回ロードするようなケースを防ぐこともできます。
このパターンを使用して「投稿の取得」リクエストのローディング状態を追跡するようにpostsSlice
を更新しましょう。状態を投稿の配列から、{posts, status, error}
のようになるように切り替えます。また、初期状態から古いサンプル投稿エントリを削除します。この変更の一環として、配列が1レベル深くなったため、配列としてのstate
の使用をすべてstate.posts
に変更する必要もあります。
import { createSlice, nanoid } from '@reduxjs/toolkit'
const initialState = {
posts: [],
status: 'idle',
error: null
}
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.posts.push(action.payload)
},
prepare(title, content, userId) {
// omit prepare logic
}
},
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
},
postUpdated(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
}
}
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = state => state.posts.posts
export const selectPostById = (state, postId) =>
state.posts.posts.find(post => post.id === postId)
はい、これは、state.posts.posts
のようなネストされたオブジェクトパスができたことを意味し、これは少し繰り返されていておかしなものです:)ネストされた配列名を、それを避けたい場合はitems
やdata
などに変更することもできますが、今のところそのままにしておきます。
createAsyncThunk
を使用したデータの取得
Redux ToolkitのcreateAsyncThunk
APIは、それらの「開始/成功/失敗」アクションを自動的にディスパッチするサンクを生成します。
まず、AJAX呼び出しを行って投稿のリストを取得するサンクを追加しましょう。src/api
フォルダーからclient
ユーティリティをインポートし、それを使用して'/fakeApi/posts'
へのリクエストを行います。
import { createSlice, nanoid, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '../../api/client'
const initialState = {
posts: [],
status: 'idle',
error: null
}
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.data
})
createAsyncThunk
は2つの引数を受け取ります。
- 生成されたアクションタイプのプレフィックスとして使用される文字列。
- データを含む
Promise
、またはエラーを含むrejectedPromise
を返す必要がある「ペイロードクリエーター」コールバック関数。
ペイロードクリエーターは通常、何らかの種類のAJAX呼び出しを行い、AJAX呼び出しからのPromise
を直接返すことも、APIレスポンスからいくつかのデータを抽出して返すこともできます。通常、JS async/await
構文を使用してこれを記述します。これにより、Promise
を使用する関数を、somePromise.then()
チェーンの代わりに標準のtry/catch
ロジックを使用して記述できます。
この場合、アクションタイプのプレフィックスとして'posts/fetchPosts'
を渡します。ペイロード作成コールバックは、API呼び出しがレスポンスを返すのを待ちます。レスポンスオブジェクトは{data: []}
のようになり、ディスパッチされたReduxアクションには、投稿の配列のみであるペイロードが必要です。したがって、response.data
を抽出し、それをコールバックから返します。
dispatch(fetchPosts())
を呼び出そうとすると、fetchPosts
サンクは最初にアクションタイプ'posts/fetchPosts/pending'
をディスパッチします。
リデューサーでこのアクションをリッスンし、リクエストステータスを'loading'
としてマークできます。
Promise
が解決すると、fetchPosts
サンクはコールバックから返されたresponse.data
配列を取得し、投稿配列をaction.payload
として含む'posts/fetchPosts/fulfilled'
アクションをディスパッチします。
コンポーネントからのサンクのディスパッチ
それでは、<PostsList>
コンポーネントを更新して、このデータを実際に自動的に取得しましょう。
fetchPosts
サンクをコンポーネントにインポートします。他のすべてのアクションクリエーターと同様に、それをディスパッチする必要があるため、useDispatch
フックも追加する必要があります。<PostsList>
がマウントされたときにこのデータを取得したいため、React useEffect
フックをインポートする必要があります。
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
// omit other imports
import { selectAllPosts, fetchPosts } from './postsSlice'
export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)
const postStatus = useSelector(state => state.posts.status)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
// omit rendering logic
}
投稿のリストを1回だけ取得しようとすることが重要です。<PostsList>
コンポーネントがレンダリングされるたび、またはビューを切り替えたために再作成されるたびに実行すると、投稿を数回取得してしまう可能性があります。posts.status
enumを使用して、コンポーネントに選択し、ステータスが'idle'
の場合にのみフェッチを開始することで、実際にフェッチを開始する必要があるかどうかを判断できます。
リデューサーとローディングアクション
次に、これらの両方のアクションをリデューサーで処理する必要があります。これには、これまで使用してきたcreateSlice
APIをもう少し詳しく調べる必要があります。
すでに、createSlice
が reducers
フィールドで定義したすべての reducer 関数に対してアクションクリエイターを生成すること、および生成されたアクションタイプにはスライスの名前が含まれることを確認しました。
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'
}
}
*/
しかし、スライス reducer が、このスライスの reducers
フィールドの一部として定義されていない他のアクションに応答する必要がある場合があります。その場合は、スライスの extraReducers
フィールドを使用できます。
extraReducers
オプションは、builder
というパラメータを受け取る関数である必要があります。 builder
オブジェクトには、スライス外で定義されたアクションに応答して実行される追加のケース reducer を定義できるメソッドが用意されています。非同期サンクによってディスパッチされる各アクションを処理するために、builder.addCase(actionCreator, reducer)
を使用します。
詳細な説明: スライスへの Extra Reducer の追加
extraReducers
の builder
オブジェクトには、スライス外で定義されたアクションに応答して実行される追加のケース reducer を定義できるメソッドが用意されています。
builder.addCase(actionCreator, reducer)
: RTK アクションクリエイターまたはプレーンなアクションタイプ文字列のいずれかに基づいて、単一の既知のアクションタイプを処理するケース reducer を定義します。builder.addMatcher(matcher, reducer)
:matcher
関数がtrue
を返す任意のアクションに応答して実行できるケース reducer を定義します。builder.addDefaultCase(reducer)
: このアクションに対して他のケース reducer が実行されなかった場合に実行されるケース reducer を定義します。
builder.addCase().addCase().addMatcher().addDefaultCase()
のように、これらをチェーンすることができます。複数の matcher がアクションに一致する場合、それらは定義された順序で実行されます。
import { increment } from '../features/counter/counterSlice'
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// slice-specific reducers here
},
extraReducers: builder => {
builder
.addCase('counter/decrement', (state, action) => {})
.addCase(increment, (state, action) => {})
}
})
この場合、fetchPosts
サンクによってディスパッチされる "pending" および "fulfilled" アクションタイプをリッスンする必要があります。これらのアクションクリエイターは実際の fetchPost
関数にアタッチされており、それらを extraReducers
に渡して、それらのアクションをリッスンできます。
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.data
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers(builder) {
builder
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts = state.posts.concat(action.payload)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
}
})
返された Promise
に基づいて、サンクによってディスパッチされる可能性のある 3 つのアクションタイプすべてを処理します。
- リクエストが開始されると、
status
enum を'loading'
に設定します。 - リクエストが成功した場合、
status
を'succeeded'
としてマークし、フェッチされた投稿をstate.posts
に追加します。 - リクエストが失敗した場合、
status
を'failed'
としてマークし、エラーメッセージを状態に保存して表示できるようにします。
ローディング状態の表示
<PostsList>
コンポーネントは、Redux に保存されている投稿の更新をすでに確認しており、そのリストが変更されるたびに自身を再レンダリングします。したがって、ページを更新すると、フェイク API からのランダムな投稿セットが画面に表示されるはずです。
使用しているフェイク API はデータをすぐに返します。ただし、実際 API 呼び出しでは、応答を返すまでに時間がかかる可能性があります。通常、UI に何らかの「読み込み中...」インジケーターを表示して、データが来るのを待っていることをユーザーに知らせるのが良いでしょう。
<PostsList>
を更新して、state.posts.status
enum に基づいて異なる UI ビットを表示できます。読み込み中の場合はスピナー、失敗した場合はエラーメッセージ、データがある場合は実際の投稿リストを表示します。ついでに、リスト内の 1 つの項目のレンダリングをカプセル化するために、<PostExcerpt>
コンポーネントを抽出するのに良いタイミングかもしれません。
結果は次のようになるかもしれません
import { Spinner } from '../../components/Spinner'
import { PostAuthor } from './PostAuthor'
import { TimeAgo } from './TimeAgo'
import { ReactionButtons } from './ReactionButtons'
import { selectAllPosts, fetchPosts } from './postsSlice'
const PostExcerpt = ({ post }) => {
return (
<article className="post-excerpt">
<h3>{post.title}</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<ReactionButtons post={post} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
)
}
export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)
const postStatus = useSelector(state => state.posts.status)
const error = useSelector(state => state.posts.error)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
let content
if (postStatus === 'loading') {
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 === 'failed') {
content = <div>{error}</div>
}
return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}
API 呼び出しの完了に時間がかかり、ローディングスピナーが数秒間画面に表示されたままになっていることに気付くかもしれません。モック API サーバーは、ローディングスピナーが表示されている時間を視覚化できるように、すべての応答に 2 秒の遅延を追加するように構成されています。この動作を変更したい場合は、api/server.js
を開き、この行を変更できます。
// Add an extra delay to all endpoints, so loading spinners show up.
const ARTIFICIAL_DELAY_MS = 2000
API 呼び出しをより速く完了させたい場合は、必要に応じてオン/オフにしてください。
ユーザーの読み込み
投稿のリストをフェッチして表示できるようになりました。しかし、投稿を見ると、問題があります。投稿のすべてに、著者として「不明な著者」と表示されています。
これは、投稿エントリがフェイク API サーバーによってランダムに生成されており、ページをリロードするたびにフェイクユーザーのセットもランダムに生成されるためです。アプリケーションの起動時に、これらのユーザーをフェッチするようにユーザーのスライスを更新する必要があります。
前回と同様に、API からユーザーを取得して返すための別の非同期サンクを作成し、extraReducers
スライスフィールドで fulfilled
アクションを処理します。ここでは、ローディング状態については気にしないでおきます。
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '../../api/client'
const initialState = []
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.data
})
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload
})
}
})
export default usersSlice.reducer
今回は、ケース reducer が state
変数をまったく使用していないことに気付いたかもしれません。代わりに、action.payload
を直接返しています。Immer では、既存の状態値を変更するか、新しい結果を返すかの 2 つの方法で状態を更新できます。新しい値を返す場合、それが返される内容で既存の状態が完全に置き換えられます。(新しい値を手動で返す場合は、必要になる可能性のある不変更新ロジックを記述するのはあなた次第であることに注意してください。)
この場合、初期状態は空の配列であり、state.push(...action.payload)
を実行して変更できた可能性があります。しかし、このケースでは、ユーザーのリストをサーバーが返したもので置き換えることを本当に望んでおり、これにより、状態内のユーザーのリストを誤って複製してしまう可能性を回避できます。
Immer での状態更新の仕組みの詳細については、RTK ドキュメントの「Immer を使用した reducer の記述」ガイドを参照してください。
ユーザーのリストを一度だけフェッチする必要があり、アプリケーションの起動時にすぐに行いたいと考えています。index.js
ファイルでそれを行うことができ、そこに store
があるため、fetchUsers
サンクを直接ディスパッチできます。
// omit other imports
import store from './app/store'
import { fetchUsers } from './features/users/usersSlice'
import { worker } from './api/server'
async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(fetchUsers())
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)
}
main()
これで、各投稿に再びユーザー名が表示され、<AddPostForm>
の「著者」ドロップダウンにも同じユーザーのリストが表示されるはずです。
新しい投稿の追加
このセクションでは、もう 1 つステップがあります。<AddPostForm>
から新しい投稿を追加すると、その投稿はアプリ内の Redux ストアにのみ追加されます。フェイク API サーバーで新しい投稿エントリを作成する API 呼び出しを実際に行う必要があり、それによって「保存」されます。(これはフェイク API であるため、ページをリロードすると新しい投稿は保持されませんが、実際のバックエンドサーバーがある場合は、次回リロード時に利用可能になります。)
サンクでのデータの送信
createAsyncThunk
は、データのフェッチだけでなく、データの送信にも役立ちます。<AddPostForm>
からの値を受け入れ、データを保存するためにフェイク API に HTTP POST 呼び出しを行うサンクを作成します。
その過程で、reducer での新しい投稿オブジェクトの処理方法を変更します。現在、postsSlice
は postAdded
の prepare
コールバックで新しい投稿オブジェクトを作成し、その投稿に新しい一意の ID を生成しています。データをサーバーに保存するほとんどのアプリでは、サーバーが一意の ID を生成し、追加フィールドを埋めることを処理し、通常は完了したデータをレスポンスで返します。そのため、{ title, content, user: userId }
のようなリクエストボディをサーバーに送信し、返される完全な投稿オブジェクトを取得して postsSlice
状態に追加できます。
export const addNewPost = createAsyncThunk(
'posts/addNewPost',
// The payload creator receives the partial `{title, content, user}` object
async initialPost => {
// We send the initial data to the fake API server
const response = await client.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) {
// omit posts loading reducers
builder.addCase(addNewPost.fulfilled, (state, action) => {
// We can directly add the new post object to our posts array
state.posts.push(action.payload)
})
}
})
コンポーネントでのサンク結果の確認
最後に、古い postAdded
アクションではなく、addNewPost
サンクをディスパッチするように <AddPostForm>
を更新します。これはサーバーへの別の API 呼び出しであるため、時間がかかり、失敗する可能性があります。addNewPost()
サンクは、pending/fulfilled/rejected
アクションを Redux ストアに自動的にディスパッチします。これはすでに処理しています。必要であれば、2 番目のローディング enum を使用して postsSlice
でリクエストの状態を追跡することもできますが、この例ではローディング状態の追跡をコンポーネントに限定しましょう。
リクエストを待っている間、「投稿を保存」ボタンを少なくとも無効にできると、ユーザーが誤って投稿を 2 回保存しようとするのを防ぐことができます。リクエストが失敗した場合、フォームにエラーメッセージを表示するか、単にコンソールに記録することもできます。
コンポーネントロジックで非同期サンクが完了するのを待機し、完了時に結果を確認できます。
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { addNewPost } from './postsSlice'
export const AddPostForm = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')
const [addRequestStatus, setAddRequestStatus] = useState('idle')
// omit useSelectors and change handlers
const canSave =
[title, content, userId].every(Boolean) && addRequestStatus === 'idle'
const onSavePostClicked = async () => {
if (canSave) {
try {
setAddRequestStatus('pending')
await dispatch(addNewPost({ title, content, user: userId })).unwrap()
setTitle('')
setContent('')
setUserId('')
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}
}
// omit rendering logic
}
React の useState
フックとして、ローディング状態 enum フィールドを追加できます。これは、投稿をフェッチするために postsSlice
でローディング状態を追跡する方法と似ています。この場合、リクエストが進行中かどうかだけを知りたいと考えています。
dispatch(addNewPost())
を呼び出すと、非同期サンクは dispatch
から Promise
を返します。ここでその Promise を await
して、サンクがリクエストをいつ完了したかを知ることができます。ただし、そのリクエストが成功したか失敗したかはまだわかりません。
createAsyncThunk
は内部的にすべてのエラーを処理するため、ログに「拒否された Promise」に関するメッセージが表示されることはありません。次に、ディスパッチされた最後のアクション(成功した場合は fulfilled
アクション、失敗した場合は rejected
アクション)を返します。
ただし、行われた実際のリクエストの成功または失敗を確認するロジックを書きたいと思うのは一般的です。Redux Toolkit は、返された Promise
に .unwrap()
関数を追加します。これは、fulfilled
アクションから実際のアクション action.payload
値を持つ新しい Promise
を返すか、rejected
アクションの場合はエラーをスローします。これにより、通常の try/catch
ロジックを使用して、コンポーネントで成功と失敗を処理できます。したがって、投稿が正常に作成された場合は入力フィールドをクリアしてフォームをリセットし、失敗した場合はエラーをコンソールに記録します。
addNewPost
API 呼び出しが失敗したときに何が起こるかを確認したい場合は、「コンテンツ」フィールドに「error」という単語のみ(引用符なし)を含む新しい投稿を作成してみてください。サーバーはそれを確認し、失敗したレスポンスを返すため、コンソールにメッセージが表示されるはずです。
学んだこと
非同期ロジックとデータフェッチは、常に複雑なトピックです。見てきたように、Redux Toolkit には、一般的な Redux データフェッチパターンを自動化するツールがいくつか含まれています。
フェイク API からデータをフェッチするようになった現在のアプリは次のようになります
念のため、このセクションで説明した内容を以下に示します。
- Reduxの状態から値を読み取る処理をカプセル化するために、再利用可能な「セレクター」関数を作成できます。
- セレクターとは、Reduxの
state
を引数として受け取り、何らかのデータを返す関数のことです。
- セレクターとは、Reduxの
- Reduxは、非同期ロジックを有効にするために「ミドルウェア」と呼ばれるプラグインを使用します。
- 標準的な非同期ミドルウェアは
redux-thunk
と呼ばれ、Redux Toolkitに含まれています。 - Thunk関数は引数として
dispatch
とgetState
を受け取り、それらを非同期ロジックの一部として使用できます。
- 標準的な非同期ミドルウェアは
- API呼び出しのローディング状態を追跡するために、追加のアクションをdispatchできます。
- 一般的なパターンは、呼び出しの前に「pending」アクションをdispatchし、その後、データを含む「success」アクションか、エラーを含む「failure」アクションのいずれかをdispatchすることです。
- ローディング状態は通常、
'idle' | 'loading' | 'succeeded' | 'failed'
のような列挙型として格納する必要があります。
- Redux Toolkitには、これらのアクションを自動的にdispatchする
createAsyncThunk
APIがあります。createAsyncThunk
は、Promise
を返す必要がある「ペイロードクリエーター」コールバックを受け入れ、pending/fulfilled/rejected
アクションタイプを自動的に生成します。fetchPosts
のような生成されたアクションクリエーターは、返されたPromise
に基づいてそれらのアクションをdispatchします。createSlice
内でextraReducers
フィールドを使用することで、これらのアクションタイプをリッスンし、それらのアクションに基づいてリデューサーで状態を更新できます。- アクションクリエーターは、
extraReducers
オブジェクトのキーを自動的に埋めるために使用でき、スライスがどのアクションをリッスンするかを把握できます。 - ThunkはPromiseを返すことができます。特に
createAsyncThunk
の場合、コンポーネントレベルでリクエストの成功または失敗を処理するために、await dispatch(someThunk()).unwrap()
を使用できます。
次は何?
Redux ToolkitのコアAPIと使用パターンについて、あと1セットのトピックがあります。パート6:パフォーマンスとデータの正規化では、Reduxの使用がReactのパフォーマンスにどのように影響するか、およびパフォーマンスを向上させるためにアプリケーションを最適化する方法について説明します。