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

Redux Essentials パート 6: パフォーマンスとデータの正規化

学習内容
  • `createSelector` を使用してメモ化されたセレクター関数を作成する方法
  • コンポーネントのレンダリングパフォーマンスを最適化するためのパターン
  • `createEntityAdapter` を使用して正規化されたデータを保存および更新する方法
前提条件
  • データフェッチフローを理解するためのパート5の完了

はじめに

パート5: 非同期ロジックとデータフェッチでは、サーバーAPIからデータをフェッチするための非同期thunkの記述方法、非同期リクエストの読み込み状態を処理するためのパターン、Reduxの状態からのデータ検索をカプセル化するためのセレクター関数の使用方法について説明しました。

このセクションでは、アプリケーションで良好なパフォーマンスを確保するための最適化されたパターンと、ストア内のデータの一般的な更新を自動的に処理するための手法について説明します。

これまでのところ、機能のほとんどは `posts` 機能に集中していました。アプリにいくつかの新しいセクションを追加します。それらが追加された後、構築方法の具体的な詳細をいくつか見て、これまでに構築したものの弱点と実装を改善する方法について説明します。

ユーザページの追加

偽のAPIからユーザーのリストをフェッチしており、新しい投稿を追加するときにユーザーを作者として選択できます。しかし、ソーシャルメディアアプリでは、特定のユーザーのページを見て、彼らが作成したすべての投稿を見る必要があります。すべてのユーザーのリストを表示するページと、特定のユーザーによるすべての投稿を表示するページを追加しましょう。

新しい `<UsersList>` コンポーネントを追加することから始めます。これは、 `useSelector` を使用してストアからいくつかのデータを読み取り、配列をマッピングして、個々のページへのリンクを含むユーザーのリストを表示するという、いつものパターンに従います。

features/users/UsersList.js
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { selectAllUsers } from './usersSlice'

export const UsersList = () => {
const users = useSelector(selectAllUsers)

const renderedUsers = users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))

return (
<section>
<h2>Users</h2>

<ul>{renderedUsers}</ul>
</section>
)
}

まだ `selectAllUsers` セレクターがないため、 `selectUserById` セレクターと一緒に `usersSlice.js` に追加する必要があります

features/users/usersSlice.js
export default usersSlice.reducer

export const selectAllUsers = state => state.users

export const selectUserById = (state, userId) =>
state.users.find(user => user.id === userId)

そして、 `<UserPage>` を追加します。これは、ルーターから `userId` パラメーターを取得するという点で、 `<SinglePostPage>` に似ています。

features/users/UserPage.js
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'

import { selectUserById } from '../users/usersSlice'
import { selectAllPosts } from '../posts/postsSlice'

export const UserPage = ({ match }) => {
const { userId } = match.params

const user = useSelector(state => selectUserById(state, userId))

const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state)
return allPosts.filter(post => post.user === userId)
})

const postTitles = postsForUser.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))

return (
<section>
<h2>{user.name}</h2>

<ul>{postTitles}</ul>
</section>
)
}

前に見たように、1つの `useSelector` 呼び出しまたはpropsからのデータを取得し、それを使用して、別の `useSelector` 呼び出しでストアから何を読み取るかを決定できます。

いつものように、 `<App>` にこれらのコンポーネントのルートを追加します

App.js
          <Route exact path="/posts/:postId" component={SinglePostPage} />
<Route exact path="/editPost/:postId" component={EditPostForm} />
<Route exact path="/users" component={UsersList} />
<Route exact path="/users/:userId" component={UserPage} />
<Redirect to="/" />

また、 `/users` にリンクする `<Navbar>` に別のタブを追加して、クリックして `<UsersList>` に移動できるようにします

app/Navbar.js
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>

<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
</div>
</div>
</section>
</nav>
)
}

通知の追加

ソーシャルメディアアプリは、誰かがメッセージを送信したり、コメントを残したり、投稿に反応したりしたことを知らせる通知がポップアップ表示されなければ完成しません。

実際のアプリケーションでは、アプリクライアントはバックエンドサーバーと常に通信しており、何かが発生するたびにサーバーはクライアントに更新をプッシュします。これは小さなサンプルアプリなので、偽のAPIからいくつかの通知エントリを実際にフェッチするボタンを追加することで、そのプロセスを模倣します。また、メッセージを送信したり、投稿に反応したりする他の*実際の*ユーザーもいないため、偽のAPIはリクエストを行うたびにランダムな通知エントリを作成します。(繰り返しますが、ここでの目標はRedux自体の使用方法を確認することです。)

通知スライス

これはアプリの新しい部分であるため、最初のステップは、通知の新しいスライスと、APIからいくつかの通知エントリをフェッチするための非同期thunkを作成することです。現実的な通知を作成するために、状態にある最新の通知のタイムスタンプを含めます。これにより、モックサーバーは、そのタイムスタンプよりも新しい通知を生成できます。

features/notifications/notificationsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

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

export const fetchNotifications = createAsyncThunk(
'notifications/fetchNotifications',
async (_, { getState }) => {
const allNotifications = selectAllNotifications(getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification ? latestNotification.date : ''
const response = await client.get(
`/fakeApi/notifications?since=${latestTimestamp}`
)
return response.data
}
)

const notificationsSlice = createSlice({
name: 'notifications',
initialState: [],
reducers: {},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})

export default notificationsSlice.reducer

export const selectAllNotifications = state => state.notifications

他のスライスと同様に、 `notificationsReducer` を `store.js` にインポートし、 `configureStore()` 呼び出しに追加します。

サーバーから新しい通知のリストを取得する `fetchNotifications` と呼ばれる非同期thunkを作成しました。その一環として、リクエストの一部として最新の通知の作成タイムスタンプを使用したいと考えています。そのため、サーバーは実際に新しい通知のみを送信する必要があることを認識しています。

通知の配列が返されることがわかっているため、それらを `state.push()` に個別の引数として渡すことができ、配列は各項目を追加します。また、サーバーが順番どおりに送信しない場合に備えて、最新の通知が配列の最初に来るようにソートされていることを確認する必要があります。(注意: `array.sort()` は常に既存の配列を変更します。これは、 `createSlice` とImmerを内部で使用しているため、安全です。)

Thunkの引数

`fetchNotifications` thunkを見ると、これまでに見たことのない新しいものがあります。Thunkの引数について少し説明しましょう。

`dispatch(addPost(newPost))` のように、ディスパッチ時にthunkアクションクリエーターに引数を渡すことができることはすでにわかっています。 `createAsyncThunk` については、1つの引数のみを渡すことができ、渡すものは何でもペイロード作成コールバックの最初の引数になります。

ペイロードクリエーターの2番目の引数は、いくつかの便利な関数と情報を含む `thunkAPI` オブジェクトです。

  • `dispatch` と `getState`:Reduxストアからの実際の `dispatch` と `getState` メソッド。これらをthunk内で使用して、より多くのアクションをディスパッチしたり、最新のReduxストア状態を取得したりできます(別のアクションがディスパッチされた後の更新された値の読み取りなど)。
  • `extra`:ストアを作成するときにthunkミドルウェアに渡すことができる「追加引数」。これは通常、アプリケーションサーバーへのAPI呼び出しを行い、データを返す方法を知っている関数のセットなど、ある種のAPIラッパーです。そのため、thunkはすべてのURLとクエリロジックを直接内部に含める必要はありません。
  • `requestId`:このthunk呼び出しの一意のランダムID値。個々のリクエストのステータスの追跡に役立ちます。
  • `signal`:進行中のリクエストをキャンセルするために使用できる `AbortController.signal` 関数。
  • `rejectWithValue`:thunkがエラーを受信した場合に、 `rejected` アクションの内容のカスタマイズに役立つユーティリティ。

( `createAsyncThunk` を使用せずにthunkを手動で記述する場合、thunk関数は `(dispatch, getState)` を1つのオブジェクトにまとめる代わりに、個別の引数として取得します。)

情報

これらの引数と、thunkとリクエストのキャンセルを処理する方法の詳細については、 `createAsyncThunk` APIリファレンスページを参照してください。

この場合、通知のリストがReduxストア状態にあり、最新の通知が配列の最初に来る必要があることがわかっています。 `thunkAPI` オブジェクトから `getState` 関数を分解し、それを呼び出して状態値を読み取り、 `selectAllNotifications` セレクターを使用して通知の配列のみを取得できます。通知の配列は最新順にソートされているため、配列の分割を使用して最新のものをつかむことができます。

通知リストの追加

そのスライスを作成したら、 `<NotificationsList>` コンポーネントを追加できます

features/notifications/NotificationsList.js
import React from 'react'
import { useSelector } from 'react-redux'
import { formatDistanceToNow, parseISO } from 'date-fns'

import { selectAllUsers } from '../users/usersSlice'

import { selectAllNotifications } from './notificationsSlice'

export const NotificationsList = () => {
const notifications = useSelector(selectAllNotifications)
const users = useSelector(selectAllUsers)

const renderedNotifications = notifications.map(notification => {
const date = parseISO(notification.date)
const timeAgo = formatDistanceToNow(date)
const user = users.find(user => user.id === notification.user) || {
name: 'Unknown User'
}

return (
<div key={notification.id} className="notification">
<div>
<b>{user.name}</b> {notification.message}
</div>
<div title={notification.date}>
<i>{timeAgo} ago</i>
</div>
</div>
)
})

return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}

ここでも、Redux状態からアイテムのリストを読み取り、それらをマッピングして、各アイテムのコンテンツをレンダリングしています。

また、 `<Navbar>` を更新して、「通知」タブと、いくつかの通知をフェッチするための新しいボタンを追加する必要があります

app/Navbar.js
import React from 'react'
import { useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'

import { fetchNotifications } from '../features/notifications/notificationsSlice'

export const Navbar = () => {
const dispatch = useDispatch()

const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}

return (
<nav>
<section>
<h1>Redux Essentials Example</h1>

<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">Notifications</Link>
</div>
<button className="button" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
</section>
</nav>
)
}

最後に、「通知」ルートで `App.js` を更新して、ナビゲートできるようにする必要があります

App.js
// omit imports
import { NotificationsList } from './features/notifications/NotificationsList'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Switch>
<Route exact path="/notifications" component={NotificationsList} />
// omit existing routes
<Redirect to="/" />
</Switch>
</div>
</Router>
)
}

これまでのところ、「通知」タブは次のようになります

Initial Notifications tab

新しい通知の表示

「通知を更新」をクリックするたびに、さらにいくつかの通知エントリがリストに追加されます。実際のアプリでは、UIの他の部分を見ている間に、サーバーから送信される可能性があります。 `<PostsList>` または `<UserPage>` を見ている間に「通知を更新」をクリックすることで、同様のことができます。しかし、今のところ、到着したばかりの通知がいくつあるかはわかりません。ボタンをクリックし続けると、まだ読んでいない通知がたくさんある可能性があります。どの通知が読み取られ、どの通知が「新しい」かを追跡するロジックを追加しましょう。これにより、ナビゲーションバーの「通知」タブに「未読」通知の数をバッジとして表示し、新しい通知を別の色で表示できます。

私たちのダミーAPIは既にisNewreadフィールドを持つ通知エントリを返送しているので、コード内でそれらを使用できます。

まず、notificationsSliceを更新して、すべての通知を既読としてマークするreducerと、既存の通知を「新規ではない」としてマークするロジックを追加します。

features/notifications/notificationsSlice.js
const notificationsSlice = createSlice({
name: 'notifications',
initialState: [],
reducers: {
allNotificationsRead(state, action) {
state.forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
state.forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

通知を表示するためにタブをクリックした場合、または既にタブを開いていて追加の通知を受信した場合など、<NotificationsList>コンポーネントがレンダリングされるたびに、これらの通知を既読としてマークします。これは、このコンポーネントが再レンダリングされるたびにallNotificationsReadをディスパッチすることで実現できます。この更新による古いデータのちらつきを避けるために、useLayoutEffectフックでアクションをディスパッチします。また、ページ内の通知リストエントリに追加のクラス名を追加して、それらを強調表示します。

features/notifications/NotificationsList.js
import React, { useLayoutEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { formatDistanceToNow, parseISO } from 'date-fns'
import classnames from 'classnames'

import { selectAllUsers } from '../users/usersSlice'

import {
selectAllNotifications,
allNotificationsRead
} from './notificationsSlice'

export const NotificationsList = () => {
const dispatch = useDispatch()
const notifications = useSelector(selectAllNotifications)
const users = useSelector(selectAllUsers)

useLayoutEffect(() => {
dispatch(allNotificationsRead())
})

const renderedNotifications = notifications.map(notification => {
const date = parseISO(notification.date)
const timeAgo = formatDistanceToNow(date)
const user = users.find(user => user.id === notification.user) || {
name: 'Unknown User'
}

const notificationClassname = classnames('notification', {
new: notification.isNew
})

return (
<div key={notification.id} className={notificationClassname}>
<div>
<b>{user.name}</b> {notification.message}
</div>
<div title={notification.date}>
<i>{timeAgo} ago</i>
</div>
</div>
)
})

return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}

これは機能しますが、実際には少し意外な動作をします。新しい通知がある場合(このタブに切り替えたばかりか、APIから新しい通知を取得した場合)、実際には2つ"notifications/allNotificationsRead"アクションがディスパッチされます。なぜでしょうか?

<PostsList>を見ながらいくつかの通知を取得し、次に「通知」タブをクリックしたとします。<NotificationsList>コンポーネントがマウントされ、最初のレンダリング後にuseLayoutEffectコールバックが実行され、allNotificationsReadがディスパッチされます。notificationsSliceは、ストア内の通知エントリを更新することでこれを処理します。これにより、不変に更新されたエントリを含む新しいstate.notifications配列が作成され、useSelectorから新しい配列が返されるため、コンポーネントが強制的に再レンダリングされ、useLayoutEffectフックが再び実行されてallNotificationsReadが2回目にディスパッチされます。reducerは再び実行されますが、今回はデータが変更されないため、コンポーネントは再レンダリングされません。

コンポーネントのマウント時に一度ディスパッチするロジックを分割し、通知配列のサイズが変更された場合にのみ再度ディスパッチするなど、2回目のディスパッチを回避する方法はいくつかあります。しかし、これは実際には何も問題を引き起こしていないため、そのままにしておきます。

これは実際には、アクションをディスパッチしても状態が全く変化しない可能性があることを示しています。状態を実際に更新する必要があるかどうかを決定するのは常にreducer次第であり、「何もする必要がない」というのはreducerが下すことができる有効な決定であることを覚えておいてください。

「新規/既読」の動作が機能するようになった通知タブの外観は次のとおりです。

New notifications

次に進む前に最後にやらなければならないことは、ナビゲーションバーの「通知」タブにバッジを追加することです。これにより、他のタブにいるときに「未読」通知の数が表示されます。

app/Navbar.js
// omit imports
import { useDispatch, useSelector } from 'react-redux'

import {
fetchNotifications,
selectAllNotifications
} from '../features/notifications/notificationsSlice'

export const Navbar = () => {
const dispatch = useDispatch()
const notifications = useSelector(selectAllNotifications)
const numUnreadNotifications = notifications.filter(n => !n.read).length
// omit component contents
let unreadNotificationsBadge

if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}
return (
<nav>
// omit component contents
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">
Notifications {unreadNotificationsBadge}
</Link>
</div>
// omit component contents
</nav>
)
}

レンダリングパフォーマンスの向上

アプリケーションは便利に見えますが、コンポーネントの再レンダリングのタイミングと方法に実際にはいくつかの欠陥があります。これらの問題点を見て、パフォーマンスを向上させる方法について説明しましょう。

レンダリング動作の調査

React DevTools Profilerを使用して、状態が更新されたときにどのコンポーネントが再レンダリングされるかのグラフを表示できます。1人のユーザーの<UserPage>をクリックしてみてください。ブラウザのDevToolsを開き、Reactの「Profiler」タブで、左上の円形の「Record」ボタンをクリックします。次に、アプリの「通知の更新」ボタンをクリックし、React DevTools Profilerで記録を停止します。次のようなチャートが表示されるはずです。

React DevTools Profiler render capture - &lt;UserPage&gt;

<Navbar>が再レンダリングされたことがわかります。これは、タブに更新された「未読通知」バッジを表示する必要があったため、理にかなっています。しかし、なぜ<UserPage>が再レンダリングされたのでしょうか?

Redux DevToolsで最後にディスパッチされたアクションをいくつか調べると、通知の状態のみが更新されたことがわかります。<UserPage>は通知を読み取らないため、再レンダリングされるべきではありません。コンポーネントに何か問題があるはずです。

<UserPage>をよく見ると、具体的な問題があります。

"features/UserPage.js
export const UserPage = ({ match }) => {
const { userId } = match.params

const user = useSelector(state => selectUserById(state, userId))

const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state)
return allPosts.filter(post => post.user === userId)
})

// omit rendering logic
}

useSelectorはアクションがディスパッチされるたびに再実行され、新しい参照値を返すとコンポーネントの再レンダリングが強制されることがわかっています。

useSelectorフック内でfilter()を呼び出しているため、このユーザーに属する投稿のリストのみが返されます。残念ながら、これはuseSelector常に新しい配列参照を返すことを意味し、投稿データが変更されていない場合でも、コンポーネントはすべてのアクションの後に再レンダリングされます!

セレクター関数のメモ化

本当に必要なのは、state.postsまたはuserIdが変更された場合にのみ、新しいフィルター済み配列を計算する方法です。変更されていない場合は、前回と同じフィルター済み配列参照を返します。

この考え方は「メモ化」と呼ばれます。以前の入力セットと計算結果を保存し、入力が同じであれば、再計算する代わりに以前の結果を返します。

これまでは、ストアからデータを読み取るためのコードをコピーして貼り付ける必要がないように、セレクター関数を自分で記述していました。セレクター関数をメモ化する方法があれば素晴らしいでしょう。

Reselectは、メモ化されたセレクター関数を作成するためのライブラリであり、Reduxで使用するために特別に設計されました。入力の変更時にのみ結果を再計算するメモ化されたセレクターを生成するcreateSelector関数があります。Redux ToolkitはcreateSelector関数をエクスポートするため、既に利用可能です。

Reselectを使用して、新しいselectPostsByUserセレクター関数を作成し、ここで使用してみましょう。

features/posts/postsSlice.js
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'

// omit slice logic

export const selectAllPosts = state => state.posts.posts

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

export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)

createSelectorは、1つ以上の「入力セレクター」関数を引数として、さらに「出力セレクター」関数を引数として取ります。selectPostsByUser(state, userId)を呼び出すと、createSelectorはすべての引数を各入力セレクターに渡します。これらの入力セレクターが返すものは何でも、出力セレクターの引数になります。

この場合、すべての投稿の配列とユーザーIDの2つを引数として出力セレクターに渡す必要があることがわかっています。既存のselectAllPostsセレクターを再利用して、投稿配列を抽出できます。ユーザーIDはselectPostsByUserに渡す2番目の引数であるため、userIdを返すだけの小さなセレクターを記述できます。

次に、出力セレクターはpostsuserIdを受け取り、そのユーザーの投稿のフィルター済み配列を返します。

selectPostsByUserを複数回呼び出そうとしても、postsまたはuserIdが変更された場合にのみ出力セレクターが再実行されます。

const state1 = getState()
// Output selector runs, because it's the first call
selectPostsByUser(state1, 'user1')
// Output selector does _not_ run, because the arguments haven't changed
selectPostsByUser(state1, 'user1')
// Output selector runs, because `userId` changed
selectPostsByUser(state1, 'user2')

dispatch(reactionAdded())
const state2 = getState()
// Output selector does not run, because `posts` and `userId` are the same
selectPostsByUser(state2, 'user2')

// Add some more posts
dispatch(addNewPost())
const state3 = getState()
// Output selector runs, because `posts` has changed
selectPostsByUser(state3, 'user2')

<UserPage>でこのセレクターを呼び出し、通知を取得中にReactプロファイラーを再実行すると、今回は<UserPage>が再レンダリングされないことがわかります。

export const UserPage = ({ match }) => {
const { userId } = match.params

const user = useSelector(state => selectUserById(state, userId))

const postsForUser = useSelector(state => selectPostsByUser(state, userId))

// omit rendering logic
}

メモ化されたセレクターは、React + Reduxアプリケーションのパフォーマンスを向上させるための貴重なツールです。不要な再レンダリングを回避し、入力データが変更されていない場合に複雑または高価な計算を行うことを回避できるためです。

情報

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

投稿リストの調査

<PostsList>に戻り、Reactプロファイラートレースをキャプチャしている間に投稿のいずれかのリアクションボタンをクリックしようとすると、<PostsList>と更新された<PostExcerpt>インスタンスがレンダリングされただけでなく、すべて<PostExcerpt>コンポーネントがレンダリングされたことがわかります。

React DevTools Profiler render capture - &lt;PostsList&gt;

なぜでしょうか?他の投稿は変更されていないので、なぜ再レンダリングする必要があるのでしょうか?

Reactのデフォルトの動作では、親コンポーネントがレンダリングされると、Reactはその中のすべての子コンポーネントを再帰的にレンダリングします!。1つの投稿オブジェクトの不変更新により、新しいposts配列も作成されました。<PostsList>は、posts配列が新しい参照であったため再レンダリングする必要があり、レンダリング後、Reactは下方に移動し続け、すべての<PostExcerpt>コンポーネントも再レンダリングしました。

これは、この小さなサンプルアプリでは深刻な問題ではありませんが、大規模な実際のアプリでは、非常に長いリストまたは非常に大きなコンポーネントツリーがあり、これらのすべての追加コンポーネントを再レンダリングすると処理速度が低下する可能性があります。

<PostsList>でこの動作を最適化するには、いくつかの方法があります。

まず、<PostExcerpt>コンポーネントをReact.memo()でラップできます。これにより、内部のコンポーネントは、小道具が実際に変更された場合にのみ再レンダリングされます。これは実際には非常にうまく機能します。試してみて、何が起こるかを確認してください。

"features/posts/PostsList.js
let PostExcerpt = ({ post }) => {
// omit logic
}

PostExcerpt = React.memo(PostExcerpt)

別のオプションは、<PostsList>を書き直して、ストアから投稿IDのリストのみを選択するようにし(posts配列全体ではなく)、<PostExcerpt>を書き直して、postIdプロップを受信し、useSelectorを呼び出して必要な投稿オブジェクトを読み取るようにすることです。<PostsList>が以前と同じIDリストを取得した場合、再レンダリングする必要がなく、変更された1つの<PostExcerpt>コンポーネントのみをレンダリングする必要があります。

残念ながら、すべての投稿を日付順にソートして正しい順序でレンダリングする必要があるため、これは複雑になります。postsSliceを更新して、配列を常にソートされた状態に保ち、コンポーネントでソートする必要がないようにし、メモ化されたセレクターを使用して投稿IDのリストのみを抽出できます。また、useSelectorが結果をチェックするために実行する比較関数をカスタマイズすることもできます(例:useSelector(selectPostIds, shallowEqual))。これにより、ID配列の内容が変更されていない場合、再レンダリングがスキップされます。

最後の選択肢は、リデューサーにすべての投稿のIDを格納する別の配列を保持させ、投稿の追加または削除時にのみその配列を変更し、<PostsList><PostExcerpt>を同様に書き換える方法を見つけることです。こうすることで、<PostsList>は、そのID配列が変更された場合にのみ再レンダリングする必要があります。

便利なことに、Redux Toolkitには、まさにそれを支援するcreateEntityAdapter関数があります。

データの正規化

多くのロジックがIDフィールドでアイテムを検索しているのを見てきました。データを配列に格納しているため、array.find()を使用して配列内のすべてのアイテムをループし、探しているIDを持つアイテムを見つける必要があります。

現実的には、これはそれほど時間はかかりませんが、数百または数千のアイテムを含む配列がある場合、配列全体を調べて1つのアイテムを見つけるのは無駄な労力になります。必要なのは、他のアイテムをすべてチェックすることなく、IDに基づいて単一のアイテムを直接検索する方法です。このプロセスは「正規化」として知られています。

正規化された状態構造

「正規化された状態」とは、

  • 状態に各データの1つのコピーのみがあり、重複がないことを意味します
  • 正規化されたデータはルックアップテーブルに保持され、アイテムIDがキー、アイテム自体が値になります。
  • 特定のアイテムタイプのすべてのIDの配列もある場合があります

JavaScriptオブジェクトは、他の言語の「マップ」や「辞書」と同様に、ルックアップテーブルとして使用できます。userオブジェクトのグループの正規化された状態は次のようになります

{
users: {
ids: ["user1", "user2", "user3"],
entities: {
"user1": {id: "user1", firstName, lastName},
"user2": {id: "user2", firstName, lastName},
"user3": {id: "user3", firstName, lastName},
}
}
}

これにより、配列内の他のすべてのユーザーオブジェクトをループすることなく、IDによって特定のuserオブジェクトを簡単に見つけることができます

const userId = 'user2'
const userObject = state.users.entities[userId]
情報

状態の正規化が役立つ理由の詳細については、「状態シェイプの正規化」およびRedux Toolkit使用ガイドの「正規化されたデータの管理」セクションを参照してください。

createEntityAdapterを使用した正規化状態の管理

Redux ToolkitのcreateEntityAdapter APIは、アイテムのコレクションを取得し、{ ids: [], entities: {} }の形式に配置することにより、スライスにデータを格納するための標準化された方法を提供します。この定義済みの状態シェイプに加えて、そのデータを操作する方法を知っている一連のリデューサー関数とセレクターを生成します。

これにはいくつかの利点があります

  • 正規化を管理するためのコードを自分で書く必要はありません
  • createEntityAdapterの組み込みリデューサー関数は、「これらすべてのアイテムを追加する」、「1つのアイテムを更新する」、「複数のアイテムを削除する」などの一般的なケースを処理します
  • createEntityAdapterは、アイテムの内容に基づいてID配列をソートされた順序で保持でき、アイテムが追加/削除された場合、またはソート順序が変更された場合にのみその配列を更新します。

createEntityAdapterは、sortComparer関数を含むことができるオプションオブジェクトを受け入れます。この関数は、2つのアイテムを比較してアイテムID配列をソートされた順序に保つために使用されます(Array.sort()と同じように機能します)。

エンティティ状態オブジェクトからアイテムを追加、更新、および削除するための一連の生成されたリデューサー関数を返します。これらのリデューサー関数は、特定のアクションタイプの場合のリデューサーとして、またはcreateSlice内の別のリデューサー内の「ミューテート」ユーティリティ関数として使用できます。

アダプターオブジェクトには、getSelectors関数もあります。Reduxルート状態からこの特定の状態スライスを返すセレクターを渡すことができ、selectAllselectByIdなどのセレクターが生成されます。

最後に、アダプターオブジェクトには、空の{ids: [], entities: {}}オブジェクトを生成するgetInitialState関数があります。getInitialStateにさらにフィールドを渡すことができ、それらはマージされます。

投稿スライスの更新

それを念頭に置いて、postsSliceを更新してcreateEntityAdapterを使用しましょう

features/posts/postsSlice.js
import {
createEntityAdapter
// omit other imports
} from '@reduxjs/toolkit'

const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

const initialState = postsAdapter.getInitialState({
status: 'idle',
error: null
})

// omit thunks

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.entities[id]
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
},
extraReducers(builder) {
// omit other reducers

builder
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
// Use the `upsertMany` reducer as a mutating update utility
postsAdapter.upsertMany(state, action.payload)
})
// Use the `addOne` reducer for the fulfilled case
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
}
})

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

export default postsSlice.reducer

// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
// Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors(state => state.posts)

export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)

そこにはたくさんのことが起こっています!それを分解しましょう。

まず、createEntityAdapterをインポートし、それを呼び出してpostsAdapterオブジェクトを作成します。すべての投稿IDの配列を最新の投稿が最初に来るようにソートしたいので、post.dateフィールドに基づいて新しいアイテムを前にソートするsortComparer関数を渡します。

getInitialState()は、空の{ids: [], entities: {}}正規化状態オブジェクトを返します。postsSliceは、読み込み状態のstatusおよびerrorフィールドも保持する必要があるため、それらをgetInitialState()に渡します。

投稿はstate.entitiesのルックアップテーブルとして保持されるようになったため、reactionAddedおよびpostUpdatedリデューサーを変更して、古いposts配列をループする代わりに、IDで適切な投稿を直接検索できます。 .

fetchPosts.fulfilledアクションを受信すると、postsAdapter.upsertMany関数を使用して、ドラフトstateaction.payloadの投稿の配列を渡すことにより、すべての受信投稿を状態に追加できます。 action.payloadに状態に既に存在するアイテムがある場合、upsertMany関数は、一致するIDに基づいてそれらをマージします。

addNewPost.fulfilledアクションを受信すると、その1つの新しい投稿オブジェクトを状態に追加する必要があることがわかります。アダプター関数をリデューサーとして直接使用できるため、そのアクションを処理するためのリデューサー関数としてpostsAdapter.addOneを渡します。

最後に、古い手書きのselectAllPostsおよびselectPostByIdセレクター関数を、postsAdapter.getSelectorsによって生成されたものに置き換えることができます。セレクターはReduxルート状態オブジェクトで呼び出されるため、Redux状態のどこで投稿データを見つけるかを知る必要があるため、state.postsを返す小さなセレクターを渡します。生成されたセレクター関数は常にselectAllおよびselectByIdと呼ばれるため、分割代入構文を使用してエクスポート時に名前を変更し、古いセレクター名と一致させることができます。ソートされた投稿IDのリストを<PostsList>コンポーネントで読み取りたいので、selectPostIdsも同じ方法でエクスポートします。

投稿リストの最適化

投稿スライスでcreateEntityAdapterが使用されるようになったので、<PostsList>を更新してレンダリング動作を最適化できます。

<PostsList>を更新して、ソートされた投稿IDの配列のみを読み取り、各<PostExcerpt>postIdを渡します。

features/posts/PostsList.js
// omit other imports

import {
selectAllPosts,
fetchPosts,
selectPostIds,
selectPostById
} from './postsSlice'

let PostExcerpt = ({ postId }) => {
const post = useSelector(state => selectPostById(state, postId))
// omit rendering logic
}

export const PostsList = () => {
const dispatch = useDispatch()
const orderedPostIds = useSelector(selectPostIds)

// omit other selections and effects

if (postStatus === 'loading') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
content = orderedPostIds.map(postId => (
<PostExcerpt key={postId} postId={postId} />
))
} else if (postStatus === 'error') {
content = <div>{error}</div>
}

// omit other rendering
}

ここで、Reactコンポーネントのパフォーマンスプロファイルをキャプチャしながら、投稿のいずれかのリアクションボタンをクリックしようとすると、 *その1つのコンポーネントのみ* が再レンダリングされたことがわかります

React DevTools Profiler render capture - optimized &lt;PostsList&gt;

他のスライスの変換

もうすぐ終わりです。最後のクリーンアップ手順として、他の2つのスライスもcreateEntityAdapterを使用するように更新します。

ユーザースライスの変換

usersSliceはかなり小さいので、変更する必要があるのはほんの少しです

features/users/usersSlice.js
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '../../api/client'

const usersAdapter = createEntityAdapter()

const initialState = usersAdapter.getInitialState()

export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.users
})

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, usersAdapter.setAll)
}
})

export default usersSlice.reducer

export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors(state => state.users)

ここで処理している唯一のアクションは、常にユーザーのリスト全体をサーバーからフェッチした配列に置き換えます。代わりにusersAdapter.setAllを使用して実装できます。

<AddPostForm>はまだstate.usersを配列として読み取ろうとしており、<PostAuthor>も同様です。それぞれselectAllUsersselectUserByIdを使用するように更新します。

通知スライスの変換

最後に、notificationsSliceも更新します

features/notifications/notificationsSlice.js
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'

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

const notificationsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

// omit fetchNotifications thunk

const notificationsSlice = createSlice({
name: 'notifications',
initialState: notificationsAdapter.getInitialState(),
reducers: {
allNotificationsRead(state, action) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
notificationsAdapter.upsertMany(state, action.payload)
Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

export const { selectAll: selectAllNotifications } =
notificationsAdapter.getSelectors(state => state.notifications)

再びcreateEntityAdapterをインポートし、呼び出して、notificationsAdapter.getInitialState()を呼び出してスライスのセットアップを支援します。

皮肉なことに、ここではすべての通知オブジェクトをループして更新する必要がある場所がいくつかあります。これらは配列に保持されなくなったため、Object.values(state.entities)を使用してこれらの通知の配列を取得し、それをループする必要があります。一方、以前のフェッチ更新ロジックをnotificationsAdapter.upsertManyに置き換えることができます。

そしてそれで... Redux Toolkitのコアコンセプトと機能の学習は完了です!

学んだこと

このセクションでは、多くの新しい動作を構築しました。これらの変更がすべて加えられたアプリがどのように見えるかを見てみましょう

このセクションで説明した内容は次のとおりです

まとめ
  • メモ化されたセレクター関数は、パフォーマンスの最適化に使用できます
    • Redux Toolkitは、メモ化されたセレクターを生成するReselectからcreateSelector関数を再エクスポートします
    • メモ化されたセレクターは、入力セレクターが新しい値を返す場合にのみ結果を再計算します
    • メモ化により、高価な計算をスキップし、同じ結果参照が返されるようにすることができます
  • Reduxを使用してReactコンポーネントのレンダリングを最適化するために使用できるパターンは複数あります
    • useSelector内で新しいオブジェクト/配列参照を作成しないでください。これらは不要な再レンダリングを引き起こします
    • メモ化されたセレクター関数をuseSelectorに渡して、レンダリングを最適化できます
    • useSelectorは、参照の等価性ではなく、shallowEqualのような代替比較関数を受け入れることができます
    • コンポーネントは、React.memo() でラップすることで、props が変更された場合にのみ再レンダリングされるようになります。
    • リストのレンダリングは、リストの親コンポーネントがアイテムIDの配列のみを読み取り、IDをリストアイテムの子に渡し、子でIDによってアイテムを取得することで最適化できます。
  • 正規化された状態構造は、アイテムを格納するための推奨されるアプローチです。
    • 「正規化」とは、データの重複がなく、アイテムIDによってルックアップテーブルにアイテムが格納されていることを意味します。
    • 正規化された状態の形状は、通常 {ids: [], entities: {}} のようになります。
  • Redux Toolkit の createEntityAdapter API は、スライス内の正規化されたデータを管理するのに役立ちます。
    • アイテムIDは、sortComparer オプションを渡すことで、ソート順に保持できます。
    • アダプターオブジェクトには以下が含まれます。
      • adapter.getInitialState は、ローディング状態などの追加の状態フィールドを受け入れることができます。
      • setAlladdManyupsertOneremoveMany など、一般的なケースに対応する事前構築済みのreducerがあります。
      • adapter.getSelectors は、selectAllselectById などのセレクターを生成します。

次は?

Redux Essentials チュートリアルにはさらにいくつかのセクションがありますが、ここで一時停止して、学んだことを実践してみることをお勧めします。

このチュートリアルでこれまでに説明した概念は、ReactとReduxを使用して独自のアプリケーションの構築を開始するのに十分なはずです。 今こそ、これらの概念を固め、実際にどのように機能するかを確認するために、自分でプロジェクトに取り組んでみる絶好の機会です。 構築するプロジェクトの種類がわからない場合は、このアプリプロジェクトのアイデアリスト を参考にしてください。

Redux Toolkit には、「RTK Query」と呼ばれる強力なデータ取得およびキャッシングAPIも含まれています。 RTK Query は、データ取得ロジックを自分で記述する必要性を完全に排除できるオプションのアドオンです。 パート7:RTK Query の基礎では、RTK Query とは何か、どのような問題を解決するのか、そしてアプリケーションでキャッシュされたデータを取得して使用する方法を学習します。

Redux Essentials チュートリアルは、「Redux の仕組み」や「なぜこのように動作するのか」ではなく、「Redux を正しく使用する方法」に焦点を当てています。 特に、Redux Toolkit はより高レベルの抽象化とユーティリティのセットであり、RTK の抽象化が実際に何をしているのかを理解することは役立ちます。 「Redux Fundamentals」チュートリアル を読むと、Redux コードを「手動で」記述する方法と、Redux Toolkit を Redux ロジックを記述するデフォルトの方法として推奨する理由を理解するのに役立ちます。

Redux の使用 セクションには、reducer の構成方法など、いくつかの重要な概念に関する情報があり、スタイルガイドページには、推奨されるパターンとベストプラクティスに関する重要な情報が記載されています。

Redux が存在する理由、Redux が解決しようとしている問題、Redux の使用方法について詳しく知りたい場合は、Redux のメンテナーである Mark Erikson の投稿Redux の道、パート1:実装と意図Redux の道、パート2:実践と哲学をご覧ください。

Redux の質問に関するヘルプが必要な場合は、Discord の Reactiflux サーバーの #redux チャンネル に参加してください。

このチュートリアルをお読みいただきありがとうございます。Redux を使用したアプリケーションの構築をお楽しみください。