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

Redux の基礎、パート 7: 標準的な Redux パターン

Redux の基礎、パート 7: 標準的な Redux パターン

学習内容
  • 実際の Redux アプリケーションで使用される標準的なパターンとその理由
    • アクションオブジェクトをカプセル化するためのアクションクリエイター
    • パフォーマンスを向上させるためのメモ化されたセレクター
    • ローディング列挙型によるリクエストステータスの追跡
    • 項目のコレクションを管理するための状態の正規化
    • Promise とサンクの操作
前提条件
  • 前のセクションのすべてのトピックを理解していること

パート 6: 非同期ロジックとデータフェッチでは、Redux ミドルウェアを使用して、ストアと通信できる非同期ロジックを記述する方法を説明しました。具体的には、Redux "サンク" ミドルウェアを使用して、事前にどの Redux ストアと通信するかを知ることなく、再利用可能な非同期ロジックを含む関数を記述しました。

これまでは、Redux が実際にどのように動作するかの基本について説明しました。ただし、実際の Redux アプリケーションでは、これらの基本の上にいくつかの追加パターンが使用されます。

重要なのは、**これらのパターンは Redux を使用するのに*必須*ではないということです!**しかし、これらの各パターンが存在するのには非常に正当な理由があり、Redux コードベースのほぼすべてに、これらのパターンの一部またはすべてが表示されます。

このセクションでは、既存の todo アプリのコードをこれらのパターンのいくつかを使用するように書き直し、それらが Redux アプリで一般的に使用される理由について説明します。次に、**パート 8**で、"最新の Redux" について説明します。これには、**公式の Redux Toolkit パッケージを使用して、アプリで"手動で"記述したすべての Redux ロジックを簡素化する方法**と、**Redux アプリを記述するための標準的なアプローチとして Redux Toolkit を推奨する理由**が含まれます。

注意

このチュートリアルでは、Redux の背後にある原則と概念を説明するために、**今日 Redux アプリを構築するための正しいアプローチとして教えている Redux Toolkit を使用した「最新の Redux」パターンよりも多くのコードを必要とする、古いスタイルの Redux ロジックパターンを意図的に示しています**。これは、本番環境に対応したプロジェクトを意図したものでは*ありません*。

Redux Toolkit を使用した「最新の Redux」の使用方法については、以下のページを参照してください。

アクションクリエイター

私たちのアプリでは、アクションオブジェクトをコード内で直接記述し、そこでディスパッチしています。

dispatch({ type: 'todos/todoAdded', payload: trimmedText })

ただし、実際には、適切に記述された Redux アプリでは、アクションをディスパッチするときに、実際にはそれらのアクションオブジェクトをインラインで記述しません。代わりに、「アクションクリエイター」関数を使用します。

**アクションクリエイター**とは、アクションオブジェクトを作成して返す関数です。通常、これらを使用するのは、アクションオブジェクトを毎回手動で記述する必要がないようにするためです。

const todoAdded = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}

その後、**アクションクリエイターを呼び出し**、**結果のアクションオブジェクトを `dispatch` に直接渡す**ことによって使用します。

store.dispatch(todoAdded('Buy milk'))

console.log(store.getState().todos)
// [ {id: 0, text: 'Buy milk', completed: false}]

詳細な説明: なぜアクションクリエイターを使用するのですか?

私たちの小さな todo アプリの例では、アクションオブジェクトを毎回手動で記述することはそれほど難しくありません。実際、アクションクリエイターを使用するように切り替えることで、*より多くの*作業を追加しました。今では、関数*と*アクションオブジェクトを記述する必要があります。

しかし、アプリケーションの多くの部分から同じアクションをディスパッチする必要がある場合はどうでしょうか?または、アクションをディスパッチするたびに、一意の ID を作成するなど、追加のロジックを実行する必要がある場合はどうでしょうか?そのアクションをディスパッチする必要があるたびに、追加のセットアップロジックをコピーして貼り付けることになります。

アクションクリエイターには、2 つの主な目的があります。

  • アクションオブジェクトの内容を準備してフォーマットします。
  • それらのアクションを作成するたびに必要となる追加の作業をカプセル化します。

そうすれば、追加の作業が必要かどうかにかかわらず、アクションを作成するための一貫したアプローチが得られます。サンクにも同じことが言えます。

アクションクリエイターの使用

todos スライスファイルを更新して、いくつかのアクションタイプにアクションクリエイターを使用してみましょう。

これまでに使用してきた 2 つの主要なアクション、サーバーからの todo リストの読み込みと、サーバーに保存した後の新しい todo の追加から始めます。

現在、`todosSlice.js` は次のようにアクションオブジェクトを直接ディスパッチしています。

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

同じ種類のアクションオブジェクトを作成して返す関数を

src/features/todos/todosSlice.js
export const todosLoaded = todos => {
return {
type: 'todos/todosLoaded',
payload: todos
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

"todo added" アクションについても、同じことができます。

src/features/todos/todosSlice.js
export const todoAdded = todo => {
return {
type: 'todos/todoAdded',
payload: todo
}
}

export function saveNewTodo(text) {
return async function saveNewTodoThunk(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch(todoAdded(response.todo))
}
}

ついでに、「color filter changed」アクションについても、同じことをしてみましょう。

src/features/filters/filtersSlice.js
export const colorFilterChanged = (color, changeType) => {
return {
type: 'filters/colorFilterChanged',
payload: { color, changeType }
}
}

このアクションは `<Footer>` コンポーネントからディスパッチされていたので、`colorFilterChanged` アクションクリエイターをインポートして使用する必要があります。

src/features/footer/Footer.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { availableColors, capitalize } from '../filters/colors'
import { StatusFilters, colorFilterChanged } from '../filters/filtersSlice'

// omit child components

const Footer = () => {
const dispatch = useDispatch()

const todosRemaining = useSelector(state => {
const uncompletedTodos = state.todos.filter(todo => !todo.completed)
return uncompletedTodos.length
})

const { status, colors } = useSelector(state => state.filters)

const onMarkCompletedClicked = () => dispatch({ type: 'todos/allCompleted' })
const onClearCompletedClicked = () =>
dispatch({ type: 'todos/completedCleared' })

const onColorChange = (color, changeType) =>
dispatch(colorFilterChanged(color, changeType))

const onStatusChange = status =>
dispatch({ type: 'filters/statusFilterChanged', payload: status })

// omit rendering output
}

export default Footer

`colorFilterChanged` アクションクリエイターは実際には 2 つの異なる引数を受け取り、それらを組み合わせて正しい `action.payload` フィールドを形成していることに注意してください。

これは、アプリケーションの動作方法や Redux データフローの動作方法を変えるものではありません。依然としてアクションオブジェクトを作成し、ディスパッチしています。しかし、コード内で常にアクションオブジェクトを直接記述する代わりに、アクションクリエイターを使用して、アクションオブジェクトをディスパッチする前に準備しています。

サンク関数でアクションクリエイターを使用することもできます。実際、前のセクションでは、サンクをアクションクリエイターでラップしました。`text` パラメーターを渡せるように、`saveNewTodo` を「サンクアクションクリエイター」関数で具体的にラップしました。`fetchTodos` はパラメーターを受け取りませんが、アクションクリエイターでラップすることもできます。

src/features/todos/todosSlice.js
export function fetchTodos() {
return async function fetchTodosThunk(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
}

つまり、`index.js` 内でディスパッチされる場所を変更して、外側のサンクアクションクリエイター関数を呼び出し、返された内側のサンク関数を `dispatch` に渡す必要があります。

src/index.js
import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos())

サンクが何をしているかを明確にするために、これまでは `function` キーワードを使用してサンクを記述してきました。ただし、代わりにアロー関数構文を使用して記述することもできます。暗黙の戻り値を使用するとコードを短縮できますが、アロー関数に慣れていない場合は、読みにくくなる可能性があります。

src/features/todos/todosSlice.js
// Same thing as the above example!
export const fetchTodos = () => async dispatch => {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

同様に、必要に応じてプレーンアクションクリエイターを短縮することも*できます*。

src/features/todos/todosSlice.js
export const todoAdded = todo => ({ type: 'todos/todoAdded', payload: todo })

アロー関数をこのように使用するのが良いかどうかは、あなた次第です。

情報

アクションクリエイターが役立つ理由の詳細については、以下を参照してください。

メモ化されたセレクター

Redux `state` オブジェクトを引数として受け取り、値を返す「セレクター」関数を記述できることをすでに説明しました。

const selectTodos = state => state.todos

いくつかのデータを*導出*する必要がある場合はどうでしょうか?たとえば、todo ID のみの配列が必要だとします。

const selectTodoIds = state => state.todos.map(todo => todo.id)

ただし、`array.map()` は常に新しい配列参照を返します。React-Redux `useSelector` フックは、*すべて*のディスパッチされたアクションの後にセレクター関数を再実行し、セレクターの結果が変更された場合、コンポーネントの再レンダリングを強制することがわかっています。

この例では、`useSelector(selectTodoIds)` を呼び出すと、 *常に* コンポーネントが *すべて* のアクションの後に再レンダリングされます。これは、新しい配列参照が返されるためです!

パート5では、`useSelector` に引数として `shallowEqual` を渡せることを確認しました。ただし、ここでは別の選択肢があります。「メモ化」されたセレクターを使用できます。

メモ化とは、一種のキャッシングです。具体的には、高コストな計算の結果を保存し、後で同じ入力があった場合にその結果を再利用することです。

メモ化されたセレクター関数は、最新の計算結果を保存するセレクターです。同じ入力で複数回呼び出された場合、同じ結果を返します。前回とは *異なる* 入力値で呼び出された場合は、新しい結果値を再計算し、キャッシュして、新しい結果を返します。

`createSelector` を使用したセレクターのメモ化

Reselect ライブラリは、メモ化されたセレクター関数を生成する `createSelector` API を提供します。 `createSelector` は、1つ以上の「入力セレクター」関を引数として、さらに「出力セレクター」を受け取り、新しいセレクター関を返します。セレクターを呼び出すたびに

  • すべての「入力セレクター」は、すべての引数を使用して呼び出されます
  • 入力セレクターの戻り値のいずれかが変更された場合、「出力セレクター」が再実行されます
  • すべての入力セレクターの結果は、出力セレクターの引数になります
  • 出力セレクターの最終結果は、次回のためにキャッシュされます

`selectTodoIds` のメモ化バージョンを作成し、それを `<TodoList>` で使用してみましょう。

まず、Reselect をインストールする必要があります

npm install reselect

次に、`createSelector` をインポートして呼び出すことができます。元の `selectTodoIds` 関数は `TodoList.js` で定義されましたが、セレクター関数は関連するスライスファイルに記述されるのが一般的です。そのため、これを todos スライスに追加してみましょう

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'

// omit reducer

// omit action creators

export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
state => state.todos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)

次に、`<TodoList>` で使用してみましょう

src/features/todos/TodoList.js
import React from 'react'
import { useSelector, shallowEqual } from 'react-redux'

import { selectTodoIds } from './todosSlice'
import TodoListItem from './TodoListItem'

const TodoList = () => {
const todoIds = useSelector(selectTodoIds)

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

これは実際には、`shallowEqual` 比較関数の動作とは少し異なります。`state.todos` 配列が変更されるたびに、結果として新しい todo ID 配列が作成されます。不変の更新のために新しい配列を作成する必要があるため、`completed` フィールドの切り替えなど、todo 項目への不変の更新も含まれます。

ヒント

メモ化されたセレクターは、元のデータから実際に追加の値を派生する場合にのみ役立ちます。既存の値を検索して返すだけの場合、セレクターはプレーン関数として保持できます。

複数の引数を持つセレクター

todo アプリは、完了ステータスに基づいて表示される todo をフィルタリングできるようになっています。そのフィルタリングされた todo のリストを返すメモ化されたセレクターを作成してみましょう。

出力セレクターへの引数の1つとして、todo 配列全体が必要であることがわかっています。現在の完了ステータスフィルター値も渡す必要があります。それぞれの値を抽出するための別の「入力セレクター」を追加し、結果を「出力セレクター」に渡します。

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'
import { StatusFilters } from '../filters/filtersSlice'

// omit other code

export const selectFilteredTodos = createSelector(
// First input selector: all todos
state => state.todos,
// Second input selector: current status filter
state => state.filters.status,
// Output selector: receives both values
(todos, status) => {
if (status === StatusFilters.All) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => todo.completed === completedStatus)
}
)
注意

2つのスライス間にインポートの依存関係が追加されたことに注意してください。`todosSlice` は `filtersSlice` から値をインポートしています。これは合法ですが、注意が必要です。**2つのスライスが *両方とも* 相互に何かをインポートしようとすると、コードがクラッシュする可能性のある「循環インポート依存関係」の問題が発生する可能性があります**。これが発生した場合は、いくつかの共通コードを独自のファイルに移動し、代わりにそのファイルからインポートしてみてください。

これで、この新しい「フィルタリングされた todo」セレクターを、それらの todo の ID を返す別のセレクターへの入力として使用できます

src/features/todos/todosSlice.js
export const selectFilteredTodoIds = createSelector(
// Pass our other memoized selector as an input
selectFilteredTodos,
// And derive data in the output selector
filteredTodos => filteredTodos.map(todo => todo.id)
)

`<TodoList>` を `selectFilteredTodoIds` を使用するように切り替えれば、いくつかの todo 項目を完了済みとしてマークできるはずです

Todo app - todos marked completed

そして、完了した todo *のみ* を表示するようにリストをフィルタリングできます

Todo app - todos marked completed

次に、`selectFilteredTodos` を拡張して、選択にカラーフィルタリングも含めることができます

src/features/todos/todosSlice.js
export const selectFilteredTodos = createSelector(
// First input selector: all todos
selectTodos,
// Second input selector: all filter values
state => state.filters,
// Output selector: receives both values
(todos, filters) => {
const { status, colors } = filters
const showAllCompletions = status === StatusFilters.All
if (showAllCompletions && colors.length === 0) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => {
const statusMatches =
showAllCompletions || todo.completed === completedStatus
const colorMatches = colors.length === 0 || colors.includes(todo.color)
return statusMatches && colorMatches
})
}
)

このセレクターにロジックをカプセル化することで、フィルタリング動作を変更してもコンポーネントを変更する必要がなかったことに注目してください。これで、ステータスと色の両方で一度にフィルタリングできます

Todo app - status and color filters

最後に、コードが `state.todos` を検索している場所がいくつかあります。このセクションの残りの部分を進めていくうちに、その状態の設計方法をいくつか変更するため、単一の `selectTodos` セレクターを抽出して、どこでも使用します。`selectTodoById` を `todosSlice` に移動することもできます

src/features/todos/todosSlice.js
export const selectTodos = state => state.todos

export const selectTodoById = (state, todoId) => {
return selectTodos(state).find(todo => todo.id === todoId)
}
情報

セレクター関数を使用する理由と Reselect でメモ化されたセレクターを作成する方法の詳細については、以下を参照してください。

非同期リクエストのステータス

非同期サンクを使用して、サーバーから todo の初期リストを取得しています。偽のサーバー API を使用しているため、その応答はすぐに返されます。実際のアプリでは、API 呼び出しの解決に時間がかかる場合があります。その場合、応答が完了するまで、何らかのローディングスピナーを表示するのが一般的です。

これは通常、Redux アプリでは次のように処理されます

  • リクエストの現在のステータスを示す何らかの「ローディング状態」値を持つ
  • API 呼び出しを行う *前に*、「リクエスト開始」アクションをディスパッチします。これは、ローディング状態値を変更することで処理されます
  • リクエストが完了したら、ローディング状態値を再度更新して、呼び出しが完了したことを示します

UI レイヤーは、リクエストが進行中である間にローディングスピナーを表示し、リクエストが完了したら実際のデータの表示に切り替えます。

todo スライスを更新してローディング状態値を追跡し、`fetchTodos` サンクの一部として追加の `'todos/todosLoading'` アクションをディスパッチします。

現在、todo リデューサーの `state` は todo の配列のみです。todo スライス内でローディング状態を追跡したい場合は、todo の状態を、todo 配列 *と* ローディング状態値を持つオブジェクトになるように再編成する必要があります。これは、追加のネストを処理するようにリデューサーロジックを書き直すことも意味します

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
entities: [...state.entities, action.payload]
}
}
case 'todos/todoToggled': {
return {
...state,
entities: state.entities.map(todo => {
if (todo.id !== action.payload) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
}
// omit other cases
default:
return state
}
}

// omit action creators

export const selectTodos = state => state.todos.entities

ここで注意すべき重要な点がいくつかあります

  • todo 配列は、`todosReducer` 状態オブジェクトで `state.entities` としてネストされるようになりました。「エンティティ」という言葉は、「ID を持つ一意の項目」を意味する用語であり、これは todo オブジェクトを説明しています。
  • これは、配列が Redux 状態オブジェクト全体で `state.todos.entities` としてネストされていることも意味します
  • `state` オブジェクト -> `entities` 配列 -> `todo` オブジェクトなど、正しい不変の更新のために追加のネストレベルをコピーするために、リデューサーで追加の手順を実行する必要があります
  • 残りのコードはセレクターを介して *のみ* todo 状態にアクセスしているため、**`selectTodos` セレクターを更新するだけで済みます**。状態を大幅に再形成しても、残りの UI は期待どおりに動作し続けます。

ローディング状態の列挙値

ローディング状態フィールドが文字列列挙型として定義されていることに気付くでしょう

{
status: 'idle' // or: 'loading', 'succeeded', 'failed'
}

`isLoading` ブール値の代わりに。

ブール値は、「ロード中」または「ロード中でない」の2つの可能性に制限されます。実際には、**リクエストが *多くの* 異なる状態にある可能性があります**。たとえば、

  • まったく開始されていない
  • 処理中
  • 成功
  • 失敗
  • 成功したが、再取得が必要な状況に戻った

また、アプリロジックは特定のアクションに基づいて特定の状態間でのみ遷移する必要があり、これはブール値を使用して実装するのが難しい場合があります。

このため、**ローディング状態はブール値フラグではなく文字列列挙値として格納することをお勧めします**。

情報

ローディング状態が列挙型である必要がある理由の詳細な説明については、以下を参照してください。

これに基づいて、ステータスを `'loading'` に設定する新しい「loading」アクションを追加し、「loaded」アクションを更新して状態フラグを `'idle'` にリセットします

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
return {
...state,
status: 'idle',
entities: action.payload
}
}
default:
return state
}
}

// omit action creators

// Thunk function
export const fetchTodos = () => async dispatch => {
dispatch(todosLoading())
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

ただし、UI に表示する前に、偽のサーバー API を変更して API 呼び出しに人為的な遅延を追加する必要があります。 `src/api/server.js` を開き、63行目付近のこのコメントアウトされた行を探します

src/api/server.js
new Server({
routes() {
this.namespace = 'fakeApi'
// this.timing = 2000

// omit other code
}
})

この行のコメントを外すと、偽のサーバーはアプリが行うすべての API 呼び出しに2秒の遅延を追加します。これにより、ローディングスピナーが実際に表示されるのに十分な時間が与えられます。

これで、`<TodoList>` コンポーネントでローディング状態値を読み取り、その値に基づいてローディングスピナーを表示できます。

src/features/todos/TodoList.js
// omit imports

const TodoList = () => {
const todoIds = useSelector(selectFilteredTodoIds)
const loadingStatus = useSelector(state => state.todos.status)

if (loadingStatus === 'loading') {
return (
<div className="todo-list">
<div className="loader" />
</div>
)
}

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

実際のアプリでは、API 障害エラーやその他の潜在的なケースも処理する必要があります。

ローディングステータスが有効になっているアプリの外観は次のとおりです(スピナーをもう一度表示するには、アプリのプレビューをリロードするか、新しいタブで開きます)

Flux Standard Actions

Redux ストア自体は、アクションオブジェクトにどのようなフィールドを配置するかは実際には気にしません。`action.type` が存在し、文字列であることのみを気にします。つまり、アクションに *任意の* 他のフィールドを追加できます。`action.todo` を「todo が追加された」アクションに使用したり、`action.color` などを使用したりできます。

ただし、すべてのアクションでデータフィールドに異なるフィールド名を使用する場合、各リデューサーで処理する必要があるフィールドを事前に知ることは困難です。

そのため、Reduxコミュニティは、「Flux Standard Actions」規約(FSA)を考案しました(「Flux Standard Actions」規約)。これは、アクションオブジェクト内のフィールドを整理する方法について推奨されるアプローチであり、開発者はどのフィールドにどのような種類のデータが含まれているかを常に把握できます。FSAパターンはReduxコミュニティで広く使用されており、実際、このチュートリアル全体を通して既に使用しています。

FSA規約では、

  • アクションオブジェクトに実際のデータがある場合、アクションの「データ」値は常にaction.payloadに配置する必要があります。
  • アクションには、追加の記述データを含むaction.metaフィールドを含めることもできます。
  • アクションには、エラー情報を含むaction.errorフィールドを含めることができます。

したがって、*すべて*のReduxアクションは、

  • プレーンなJavaScriptオブジェクトである必要があります。
  • typeフィールドを持っている必要があります。

FSAパターンを使用してアクションを記述する場合、アクションは、

  • payloadフィールドを持つことができます。
  • errorフィールドを持つことができます。
  • metaフィールドを持つことができます。

詳細な説明:FSAとエラー

FSA仕様では、

アクションがエラーを表す場合、オプションのerrorプロパティをtrueに設定できます。errorがtrueのアクションは、拒否されたPromiseに類似しています。慣例により、payloadはエラーオブジェクトである必要があります。errortrue以外の値(undefinedおよびnullを含む)を持つ場合、アクションはエラーとして解釈されてはなりません。

FSA仕様では、「読み込み成功」や「読み込み失敗」などの特定のアクションタイプを使用することにも反対しています。

ただし、実際には、Reduxコミュニティは、action.errorをブール値フラグとして使用する考え方を無視し、代わりに'todos/todosLoadingSucceeded''todos/todosLoadingFailed'などの別々のアクションタイプを使用することに落ち着きました。これは、'todos/todosLoaded'を処理してからif (action.error)をチェックするよりも、これらのアクションタイプをチェックする方がはるかに簡単だからです。

自分に合った方法を使用できますが、ほとんどのアプリは成功と失敗に別々のアクションタイプを使用しています。

正規化された状態

これまで、todosを配列に保持してきました。これは、サーバーからデータが配列として受信され、UIにリストとして表示するためにtodosをループする必要があるため、合理的です。

ただし、大規模なReduxアプリでは、データを**正規化された状態構造**に格納するのが一般的です。「正規化」とは、

  • 各データのコピーが1つだけであることを確認することです。
  • IDでアイテムを直接見つけることができる方法でアイテムを格納することです。
  • アイテム全体をコピーする代わりに、IDに基づいて他のアイテムを参照することです。

たとえば、ブログアプリケーションでは、UserオブジェクトとCommentオブジェクトを指すPostオブジェクトがある場合があります。同じ人物による投稿が多数ある可能性があるため、すべてのPostオブジェクトにUserオブジェクト全体が含まれている場合、同じUserオブジェクトのコピーが多数存在することになります。代わりに、Postオブジェクトはpost.userとしてユーザーID値を持ち、state.users[post.user]としてIDでUserオブジェクトを検索できます。

これは、通常、配列ではなくオブジェクトとしてデータを整理することを意味します。アイテムIDはキーであり、アイテム自体は値です。次に例を示します。

const rootState = {
todos: {
status: 'idle',
entities: {
2: { id: 2, text: 'Buy milk', completed: false },
7: { id: 7, text: 'Clean room', completed: true }
}
}
}

todosスライスをtodosを正規化された形式で格納するように変換してみましょう。これには、リデューサーロジックのかなりの変更とセレクターの更新が必要です。

src/features/todos/todosSlice
const initialState = {
status: 'idle',
entities: {}
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
const todo = action.payload
return {
...state,
entities: {
...state.entities,
[todo.id]: todo
}
}
}
case 'todos/todoToggled': {
const todoId = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
completed: !todo.completed
}
}
}
}
case 'todos/colorSelected': {
const { color, todoId } = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
color
}
}
}
}
case 'todos/todoDeleted': {
const newEntities = { ...state.entities }
delete newEntities[action.payload]
return {
...state,
entities: newEntities
}
}
case 'todos/allCompleted': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
newEntities[todo.id] = {
...todo,
completed: true
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/completedCleared': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
if (todo.completed) {
delete newEntities[todo.id]
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
return {
...state,
status: 'idle',
entities: newEntities
}
}
default:
return state
}
}

// omit action creators

const selectTodoEntities = state => state.todos.entities

export const selectTodos = createSelector(selectTodoEntities, entities =>
Object.values(entities)
)

export const selectTodoById = (state, todoId) => {
return selectTodoEntities(state)[todoId]
}

state.entitiesフィールドは配列ではなくオブジェクトになったため、配列操作ではなく、ネストされたオブジェクトスプレッド演算子を使用してデータを更新する必要があります。また、配列をループするのと同じ方法でオブジェクトをループすることはできないため、Object.values(entities)を使用してtodoアイテムの配列を取得し、それらをループする必要がある場所がいくつかあります。

良いニュースは、状態ルックアップをカプセル化するためにセレクターを使用しているため、UIを変更する必要がないことです。悪いニュースは、リデューサーコードが実際にはより長く、より複雑になっていることです。

ここでの問題の一部は、**このtodoアプリの例は大規模な実際のアプリケーションではない**ということです。そのため、この特定のアプリでは状態の正規化はそれほど役に立たず、潜在的な利点を確認するのが困難です。

幸いなことに、パート8:Redux Toolkitを使用した最新のReduxでは、正規化された状態を管理するためのリデューサーロジックを大幅に短縮する方法をいくつか紹介します。

今のところ、理解しておくべき重要なことは、

  • 正規化はReduxアプリで一般的に使用されているということです。
  • 主な利点は、IDで個々のアイテムを検索でき、状態にアイテムのコピーが1つだけ存在することを保証できることです。
情報

Reduxで正規化が役立つ理由の詳細については、以下を参照してください。

サンクとPromise

このセクションで見るべきパターンは、あと1つです。ディスパッチされたアクションに基づいてReduxストアで読み込み状態を処理する方法については、既に説明しました。コンポーネントでサンクの結果を確認する必要がある場合はどうでしょうか。

store.dispatch(action)を呼び出すたびに、dispatchは実際には結果としてactionを返します。ミドルウェアはその動作を変更し、代わりに他の値を返すことができます。

Redux Thunkミドルウェアを使用すると、関数をdispatchに渡して関数を呼び出し、結果を返すことができることを既に確認しました。

reduxThunkMiddleware.js
const reduxThunkMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
// Also, return whatever the thunk function returns
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

これは、**promiseを返すサンク関数を作成し、コンポーネントでそのpromiseを待つことができる**ことを意味します。

既に<Header>コンポーネントにサンクをディスパッチして、新しいtodoエントリをサーバーに保存しています。<Header>コンポーネント内に読み込み状態を追加し、サーバーを待っている間にテキスト入力を無効にして別のローディングスピナーを表示してみましょう。

src/features/header/Header.js
const Header = () => {
const [text, setText] = useState('')
const [status, setStatus] = useState('idle')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = async e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create and dispatch the thunk function itself
setStatus('loading')
// Wait for the promise returned by saveNewTodo
await dispatch(saveNewTodo(trimmedText))
// And clear out the text input
setText('')
setStatus('idle')
}
}

let isLoading = status === 'loading'
let placeholder = isLoading ? '' : 'What needs to be done?'
let loader = isLoading ? <div className="loader" /> : null

return (
<header className="header">
<input
className="new-todo"
placeholder={placeholder}
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{loader}
</header>
)
}

export default Header

これで、todoを追加すると、ヘッダーにスピナーが表示されます。

Todo app - component loading spinner

学習内容

ご覧のとおり、Reduxアプリで広く使用されている追加パターンがいくつかあります。これらのパターンは必須ではなく、最初はより多くのコードを記述する必要がある場合がありますが、ロジックの再利用、実装の詳細のカプセル化、アプリのパフォーマンスの向上、データの検索の容易化などの利点があります。

情報

これらのパターンが存在する理由とReduxの使用方法の詳細については、以下を参照してください。

これらのパターンを使用して完全に変換された後のアプリの外観は次のとおりです。

まとめ
  • アクションクリエーター関数は、アクションオブジェクトとサンクの準備をカプセル化します。
    • アクションクリエーターは引数を受け入れてセットアップロジックを含めることができ、最終的なアクションオブジェクトまたはサンク関数を返します。
  • メモ化されたセレクターは、Reduxアプリのパフォーマンスを向上させるのに役立ちます。
    • Reselectには、メモ化されたセレクターを生成するcreateSelector APIがあります。
    • メモ化されたセレクターは、同じ入力が与えられた場合、同じ結果参照を返します。
  • リクエストステータスは、ブール値ではなく列挙型として格納する必要があります。
    • 'idle''loading'などの列挙型を使用すると、ステータスを一貫して追跡できます。
  • 「Flux Standard Actions」は、アクションオブジェクトを整理するための一般的な規則です。
    • アクションは、データにpayload、追加の説明にmeta、エラーにerrorを使用します。
  • 正規化された状態により、IDでアイテムを簡単に見つけることができます。
    • 正規化されたデータは、配列ではなくオブジェクトに格納され、アイテムIDがキーとして使用されます。
  • サンクはdispatchからpromiseを返すことができます。
    • コンポーネントは非同期サンクの完了を待ってから、さらに作業を行うことができます。

次のステップ

このすべてのコードを「手動で」記述すると、時間と労力がかかる場合があります。**そのため、Reduxロジックを記述するには、公式のRedux Toolkitパッケージを使用することをお勧めします。**

Redux Toolkitには、**典型的なReduxの使用パターンをすべて記述するのに役立つAPIが含まれていますが、コードは少なくなります。**また、状態を誤って変更してしまうなどの**よくある間違いを防ぐ**のにも役立ちます。

パート8:最新のReduxでは、Redux Toolkitを使用してこれまでに記述したすべてのコードを簡素化する方法について説明します。