Redux Essentials、パート4:Reduxデータの使用方法
- 複数のReactコンポーネントでReduxデータを使用する
- アクションをディスパッチするロジックを整理する
- Reducerでより複雑な更新ロジックを作成する
- パート3のReduxデータフローとReact-Redux APIを理解していること
- React Routerの``および`
`コンポーネントによるページルーティング に精通していること
はじめに
パート3:基本的なReduxデータフローでは、空のRedux+Reactプロジェクト設定から開始し、新しい状態のスライスを追加し、Reduxストアからデータを読み取り、そのデータを更新するためにアクションをディスパッチできるReactコンポーネントを作成する方法を示しました。また、コンポーネントがアクションをディスパッチし、Reducerがアクションを処理して新しい状態を返し、コンポーネントが新しい状態を読み取ってUIを再レンダリングするという、アプリケーション内でのデータフローについても説明しました。
Reduxロジックを作成するコアステップを理解したので、今度はそれらの同じステップを使用して、ソーシャルメディアフィードにいくつかの新機能を追加して、より便利にします。単一の投稿の表示、既存の投稿の編集、投稿作成者詳細、投稿タイムスタンプ、反応ボタンの表示などです。
念のためですが、コード例は各セクションの主要な概念と変更に焦点を当てています。CodeSandboxプロジェクトとプロジェクトリポジトリの`tutorial-steps`ブランチで、アプリケーションの完全な変更を確認してください。
単一投稿の表示
Reduxストアに新しい投稿を追加する機能があるので、投稿データをさまざまな方法で使用できるいくつかの機能を追加できます。
現在、投稿エントリはメインフィードページに表示されていますが、テキストが長すぎる場合は、コンテンツの抜粋のみが表示されます。単一の投稿エントリを独自のページで表示できる機能があると便利です。
単一投稿ページの作成
まず、`posts`フィーチャーフォルダーに新しい`SinglePostPage`コンポーネントを追加する必要があります。ページURLが` /posts/123`のようになっている場合にこのコンポーネントを表示するためにReact Routerを使用します。ここで、`123`の部分は表示する投稿のIDになります。
import React from 'react'
import { useSelector } from 'react-redux'
export const SinglePostPage = ({ match }) => {
const { postId } = match.params
const post = useSelector(state =>
state.posts.find(post => post.id === postId)
)
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<p className="post-content">{post.content}</p>
</article>
</section>
)
}
React Routerは、探しているURL情報を含む`match`オブジェクトをプロップとして渡します。このコンポーネントをレンダリングするルートを設定するときは、URLの2番目の部分を`postId`という名前の変数として解析するように指示し、`match.params`からその値を読み取ることができます。
その`postId`値を取得したら、セレクター関数内で使用して、Reduxストアから正しい投稿オブジェクトを見つけることができます。`state.posts`はすべての投稿オブジェクトの配列であることがわかっているので、`Array.find()`関数を使用して配列をループし、探しているIDを持つ投稿エントリを返すことができます。
重要なのは、**`useSelector`から返された値が新しい参照に変更されるたびに、コンポーネントが再レンダリングされる**ことです。コンポーネントは常に、ストアから必要な最小限のデータを選択しようとすべきです。これにより、実際に必要になった場合にのみレンダリングされるようになります。
ストアに一致する投稿エントリがない可能性があります。ユーザーがURLを直接入力しようとした場合、または正しいデータがロードされていない場合などです。その場合、`find()`関数は実際の投稿オブジェクトではなく`undefined`を返します。コンポーネントはそれをチェックし、ページに「投稿が見つかりません!」というメッセージを表示することで処理する必要があります。
ストアに正しい投稿オブジェクトがあると仮定すると、`useSelector`はそれを返し、それを用いてページに投稿のタイトルとコンテンツをレンダリングできます。
メインフィードに投稿の抜粋を表示するために、`posts`配列全体をループする`<PostsList>`コンポーネントの本体にあるロジックと非常に似ていることに気付くかもしれません。投稿の抜粋と投稿全体を表示する方法に既にいくつかの違いがあるので、両方の場所で使える`Post`コンポーネントを抽出しようと*する*ことができますが、。重複があっても、しばらくの間は個別に書き続ける方が通常は優れており、後でコードの異なるセクションが十分に類似しているかどうかを判断し、再利用可能なコンポーネントを抽出できるかどうかを判断できます。
単一投稿ルートの追加
`<SinglePostPage>`コンポーネントができたので、それを表示するルートを定義し、最初のページのフィードにある各投稿へのリンクを追加できます。
`App.js`で`SinglePostPage`をインポートし、ルートを追加します。
import { PostsList } from './features/posts/PostsList'
import { AddPostForm } from './features/posts/AddPostForm'
import { SinglePostPage } from './features/posts/SinglePostPage'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Switch>
<Route
exact
path="/"
render={() => (
<React.Fragment>
<AddPostForm />
<PostsList />
</React.Fragment>
)}
/>
<Route exact path="/posts/:postId" component={SinglePostPage} />
<Redirect to="/" />
</Switch>
</div>
</Router>
)
}
次に、`<PostsList>`で、リストのレンダリングロジックを更新して、その特定の投稿にルーティングされる`<Link>`を含めます。
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
export const PostsList = () => {
const posts = useSelector(state => state.posts)
const renderedPosts = posts.map(post => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
))
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
)
}
そして、別のページに移動できるようになったので、`<Navbar>`コンポーネントにもメイン投稿ページへのリンクを追加するのも役立ちます。
import React from 'react'
import { Link } from 'react-router-dom'
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
</div>
</div>
</section>
</nav>
)
}
投稿の編集
ユーザーとして、投稿の書き込みを終えて保存し、どこかに間違いがあったことに気付くのは非常に面倒です。作成後に投稿を編集できる機能があると便利です。
既存の投稿IDを取り、ストアからその投稿を読み取り、ユーザーがタイトルと投稿コンテンツを編集できるようにし、変更を保存してストアの投稿を更新できる新しい`<EditPostForm>`コンポーネントを追加しましょう。
投稿エントリの更新
まず、ストアが投稿を実際に更新する方法を知るために、新しいReducer関数とアクションを作成するように`postsSlice`を更新する必要があります。
`createSlice()`呼び出しの中で、`reducers`オブジェクトに新しい関数を追加する必要があります。このReducerの名前は、何が起こっているかの良い説明である必要があります。このアクションがディスパッチされるたびに、Redux DevToolsの一部としてReducerの名前が表示されるためです。最初のReducerは`postAdded`と呼ばれていたので、これを`postUpdated`と呼びましょう。
投稿オブジェクトを更新するには、次のものが必要です。
- 更新する投稿のID(状態から正しい投稿オブジェクトを見つけるため)
- ユーザーが入力した新しい`title`と`content`フィールド
Reduxアクションオブジェクトには、通常は説明的な文字列である`type`フィールドが必要です。また、何が起こったかについての詳細情報を含む他のフィールドを含むこともできます。慣例により、通常は追加情報を`action.payload`というフィールドに入れますが、`payload`フィールドに何を入れるかは私たち次第です。文字列、数値、オブジェクト、配列、またはその他の何かでも構いません。今回は、必要な情報が3つあるので、`payload`フィールドに3つのフィールドを含むオブジェクトを入れることを計画しましょう。つまり、アクションオブジェクトは`{type: 'posts/postUpdated', payload: {id, title, content}}`のようになります。
`createSlice`によって生成されたアクションクリエーターは、デフォルトで1つの引数の受け渡しを期待しており、その値は`action.payload`としてアクションオブジェクトに入れられます。そのため、これらのフィールドを含むオブジェクトを`postUpdated`アクションクリエーターへの引数として渡すことができます。
また、アクションがディスパッチされたときに状態をどのように更新するべきかを決定するのはReducerの役割であることもわかっています。それを考えると、ReducerはIDに基づいて正しい投稿オブジェクトを見つけ、その投稿の`title`と`content`フィールドを具体的に更新する必要があります。
最後に、UIがユーザーが投稿を保存したときに新しい`postUpdated`アクションをディスパッチできるように、`createSlice`によって生成されたアクションクリエーター関数をエクスポートする必要があります。
これらの要件をすべて考慮して、作業が完了した後、`postsSlice`の定義は次のようになります。
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded(state, action) {
state.push(action.payload)
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})
export const { postAdded, postUpdated } = postsSlice.actions
export default postsSlice.reducer
投稿編集フォームの作成
新しい`<EditPostForm>`コンポーネントは`<AddPostForm>`と似ていますが、ロジックは少し異なります。ストアから正しい`post`オブジェクトを取得し、それをコンポーネントの状態フィールドの初期化に使用して、ユーザーが変更できるようにする必要があります。ユーザーが完了したら、変更されたタイトルとコンテンツの値をストアに保存します。また、React Routerの履歴APIを使用して、単一投稿ページに切り替え、その投稿を表示します。
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory } from 'react-router-dom'
import { postUpdated } from './postsSlice'
export const EditPostForm = ({ match }) => {
const { postId } = match.params
const post = useSelector(state =>
state.posts.find(post => post.id === postId)
)
const [title, setTitle] = useState(post.title)
const [content, setContent] = useState(post.content)
const dispatch = useDispatch()
const history = useHistory()
const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)
const onSavePostClicked = () => {
if (title && content) {
dispatch(postUpdated({ id: postId, title, content }))
history.push(`/posts/${postId}`)
}
}
return (
<section>
<h2>Edit Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
placeholder="What's on your mind?"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
</form>
<button type="button" onClick={onSavePostClicked}>
Save Post
</button>
</section>
)
}
`SinglePostPage`と同様に、`App.js`にインポートし、`postId`をルートパラメーターとしてこのコンポーネントをレンダリングするルートを追加する必要があります。
import { PostsList } from './features/posts/PostsList'
import { AddPostForm } from './features/posts/AddPostForm'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Switch>
<Route
exact
path="/"
render={() => (
<React.Fragment>
<AddPostForm />
<PostsList />
</React.Fragment>
)}
/>
<Route exact path="/posts/:postId" component={SinglePostPage} />
<Route exact path="/editPost/:postId" component={EditPostForm} />
<Redirect to="/" />
</Switch>
</div>
</Router>
)
}
また、`SinglePostPage`に`EditPostForm`にルーティングする新しいリンクを追加する必要があります。例:
import { Link } from 'react-router-dom'
export const SinglePostPage = ({ match }) => {
// omit other contents
<p className="post-content">{post.content}</p>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
アクションペイロードの準備
`createSlice`のアクションクリエイターは通常、1つの引数(`action.payload`になる)を受け入れることを確認しました。これは最も一般的な使用パターンを簡素化しますが、アクションオブジェクトの内容を準備するために、より多くの作業が必要になる場合があります。`postAdded`アクションの場合、新しい投稿の一意のIDを生成する必要があり、ペイロードが`{id, title, content}`のようなオブジェクトであることを確認する必要があります。
現在、IDを生成し、Reactコンポーネントでペイロードオブジェクトを作成し、ペイロードオブジェクトを`postAdded`に渡しています。しかし、異なるコンポーネントから同じアクションをディスパッチする必要がある場合、またはペイロードの準備ロジックが複雑な場合はどうでしょうか?アクションをディスパッチするたびにそのロジックを複製する必要があり、コンポーネントにこのアクションのペイロードがどのようなものになるかを正確に認識させる必要があります。
アクションに一意のIDまたはその他のランダムな値を含める必要がある場合は、常に最初に生成し、アクションオブジェクトに配置します。**リデューサーはランダムな値を計算すべきではありません**。計算結果が予測不可能になるためです。
`postAdded`アクションクリエイターを手動で記述していた場合、セットアップロジックを内部に配置できます。
// hand-written action creator
function postAdded(title, content) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}
しかし、Redux Toolkitの`createSlice`はこれらのアクションクリエイターを生成しています。これにより、自分で記述する必要がないためコードが短くなりますが、`action.payload`の内容をカスタマイズする方法が必要です。
幸いなことに、`createSlice`を使用すると、リデューサーを記述する際に「prepareコールバック」関数を定義できます。「prepareコールバック」関数は複数の引数を受け入れ、一意のIDなどのランダムな値を生成し、アクションオブジェクトにどのような値を入れるかを決定するために必要なその他の同期ロジックを実行できます。その後、`payload`フィールドを含むオブジェクトを返す必要があります。(戻り値のオブジェクトには`meta`フィールドを含めることもできます。これはアクションに追加の説明的な値を追加するために使用でき、`error`フィールドは、このアクションが何らかのエラーを表すかどうかを示すブール値である必要があります。)
`createSlice`の`reducers`フィールド内で、フィールドの1つを`{reducer, prepare}`のようなオブジェクトとして定義できます。
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.push(action.payload)
},
prepare(title, content) {
return {
payload: {
id: nanoid(),
title,
content
}
}
}
}
// other reducers here
}
})
これで、コンポーネントはペイロードオブジェクトがどのようなものかについて心配する必要がなくなりました。アクションクリエイターが適切な方法でペイロードオブジェクトをまとめるためです。そのため、`postAdded`をディスパッチするときに引数として`title`と`content`を渡すようにコンポーネントを更新できます。
const onSavePostClicked = () => {
if (title && content) {
dispatch(postAdded(title, content))
setTitle('')
setContent('')
}
}
ユーザーと投稿
これまでのところ、状態のスライスは1つだけです。ロジックは`postsSlice.js`で定義され、データは`state.posts`に保存され、すべてのコンポーネントは投稿機能に関連付けられています。実際のアプリケーションでは、おそらく多くの異なる状態のスライスと、ReduxロジックとReactコンポーネントのいくつかの異なる「機能フォルダ」を持つでしょう。
他のユーザーが関与していない場合、「ソーシャルメディア」アプリを持つことはできません。アプリにユーザーリストを追跡する機能を追加し、そのデータを使用するように投稿関連の機能を更新しましょう。
ユーザーのスライスの追加
「ユーザー」の概念は「投稿」の概念とは異なるため、ユーザーのコードとデータを投稿のコードとデータから分離する必要があります。新しい`features/users`フォルダを追加し、そこに`usersSlice`ファイルを入れます。投稿のスライスと同様に、ここではデータを扱うためにいくつかの初期エントリを追加します。
import { createSlice } from '@reduxjs/toolkit'
const initialState = [
{ id: '0', name: 'Tianna Jenkins' },
{ id: '1', name: 'Kevin Grant' },
{ id: '2', name: 'Madison Price' }
]
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {}
})
export default usersSlice.reducer
現時点では、実際にデータを更新する必要がないため、`reducers`フィールドは空のオブジェクトのままにします。(これは後のセクションで説明します。)
以前と同様に、ストアファイルに`usersReducer`をインポートし、ストア設定に追加します。
import { configureStore } from '@reduxjs/toolkit'
import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
export default configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})
投稿の作者の追加
アプリのすべての投稿はユーザーの1人が作成しており、新しい投稿を追加するたびに、どのユーザーがその投稿を作成したかを追跡する必要があります。実際のアプリでは、現在のログインユーザーを追跡する何らかの`state.currentUser`フィールドがあり、投稿を追加するたびにその情報を使用します。
この例を簡素化するために、ドロップダウンリストからユーザーを選択できるよう`<AddPostForm>`コンポーネントを更新し、そのユーザーのIDを投稿の一部として含めます。投稿オブジェクトにユーザーIDが含まれるようになると、それを使用してユーザーの名前を検索し、UIの各個々の投稿に表示できます。
まず、`postAdded`アクションクリエイターを更新して、ユーザーIDを引数として受け入れ、アクションに含める必要があります。(`initialState`の既存の投稿エントリも、例としてユーザーIDの1つを持つ`post.user`フィールドを持つように更新します。)
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.push(action.payload)
},
prepare(title, content, userId) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId
}
}
}
}
// other reducers
}
})
これで、`<AddPostForm>`で、`useSelector`を使用してストアからユーザーリストを読み取り、ドロップダウンとして表示できます。次に、選択したユーザーのIDを取得し、`postAdded`アクションクリエイターに渡します。ついでに、タイトルとコンテンツの入力に実際のテキストがある場合にのみユーザーが「投稿を保存」ボタンをクリックできるように、フォームに少し検証ロジックを追加できます。
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { postAdded } from './postsSlice'
export const AddPostForm = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')
const dispatch = useDispatch()
const users = useSelector(state => state.users)
const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)
const onAuthorChanged = e => setUserId(e.target.value)
const onSavePostClicked = () => {
if (title && content) {
dispatch(postAdded(title, content, userId))
setTitle('')
setContent('')
}
}
const canSave = Boolean(title) && Boolean(content) && Boolean(userId)
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Add a New Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
placeholder="What's on your mind?"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={onAuthorChanged}>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
<button type="button" onClick={onSavePostClicked} disabled={!canSave}>
Save Post
</button>
</form>
</section>
)
}
次に、投稿リストアイテムと`<SinglePostPage>`内に投稿の作者の名前を表示する方法が必要です。この種の情報を複数の場所で表示したいので、ユーザーIDをプロップとして受け取り、正しいユーザーオブジェクトを検索し、ユーザーの名前をフォーマットする`PostAuthor`コンポーネントを作成します。
import React from 'react'
import { useSelector } from 'react-redux'
export const PostAuthor = ({ userId }) => {
const author = useSelector(state =>
state.users.find(user => user.id === userId)
)
return <span>by {author ? author.name : 'Unknown author'}</span>
}
各コンポーネントで同じパターンに従っていることに注意してください。Reduxストアからデータを読み取る必要があるコンポーネントは、`useSelector`フックを使用して、必要な特定のデータ部分を抽出できます。また、多くのコンポーネントが同時にReduxストア内の同じデータにアクセスできます。
これで、`PostAuthor`コンポーネントを`PostsList.js`と`SinglePostPage.js`の両方にインポートし、`<PostAuthor userId={post.user} />`としてレンダリングできます。投稿エントリを追加するたびに、選択したユーザーの名前がレンダリングされた投稿内に表示されます。
投稿機能の追加
現時点では、投稿の作成と編集ができます。投稿フィードをより便利にするために、追加のロジックを追加しましょう。
投稿の日付の保存
ソーシャルメディアフィードは通常、投稿が作成された時刻によってソートされ、「5時間前」などの相対的な説明として投稿作成時刻が表示されます。そのためには、投稿エントリに`date`フィールドを追跡する必要があります。
`post.user`フィールドと同様に、`postAdded` prepareコールバックを更新して、アクションがディスパッチされるときに`post.date`が常に含まれるようにします。ただし、それは渡される別のパラメーターではありません。アクションがディスパッチされたときの正確なタイムスタンプを常に使用したいので、prepareコールバック自体で処理させます。
Reduxのアクションと状態には、オブジェクト、配列、プリミティブなどのプレーンなJS値のみを含める必要があります。Reduxにクラスインスタンス、関数、その他のシリアライズ不可能な値を入れないでください!.
`Date`クラスインスタンスをReduxストアに直接入れることはできないため、`post.date`値をタイムスタンプ文字列として追跡します。
postAdded: {
reducer(state, action) {
state.push(action.payload)
},
prepare(title, content, userId) {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
},
}
},
},
投稿の作者と同様に、`<PostsList>`と`<SinglePostPage>`コンポーネントの両方で相対的なタイムスタンプの説明を表示する必要があります。タイムスタンプ文字列を相対的な説明としてフォーマットする`<TimeAgo>`コンポーネントを追加します。`date-fns`などのライブラリには、日付の解析とフォーマットに役立つユーティリティ関数がいくつかあり、ここで使用できます。
import React from 'react'
import { parseISO, formatDistanceToNow } from 'date-fns'
export const TimeAgo = ({ timestamp }) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}
return (
<span title={timestamp}>
<i>{timeAgo}</i>
</span>
)
}
投稿リストのソート
`<PostsList>`は現在、投稿がReduxストアに保持されているのと同じ順序ですべての投稿を表示しています。この例では、最も古い投稿が最初に表示され、新しい投稿を追加するたびに、投稿配列の最後に追加されます。つまり、最新の投稿は常にページの一番下にあります。
通常、ソーシャルメディアフィードでは最新の投稿が最初に表示され、下にスクロールすると古い投稿が表示されます。データはストアに古い順に保持されていますが、`<PostsList>`コンポーネントでデータを並べ替えて、最新の投稿が最初に表示されるようにできます。理論的には、`state.posts`配列がすでにソートされていることがわかっているので、リストを反転するだけで済みます。しかし、念のため、自分でソートする方が良いでしょう。
`array.sort()`は既存の配列を変更するため、`state.posts`のコピーを作成し、そのコピーをソートする必要があります。`post.date`フィールドは日付タイムスタンプ文字列として保持されており、それらを直接比較して投稿を正しい順序でソートできます。
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
const renderedPosts = orderedPosts.map(post => {
return (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
)
})
`postsSlice.js`の`initialState`に`date`フィールドを追加する必要があります。ここでは、`date-fns`を使用して現在の日時分から分を引いて、互いに異なるようにします。
import { createSlice, nanoid } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
const initialState = [
{
// omitted fields
content: 'Hello!',
date: sub(new Date(), { minutes: 10 }).toISOString()
},
{
// omitted fields
content: 'More text',
date: sub(new Date(), { minutes: 5 }).toISOString()
}
]
投稿のリアクションボタン
このセクションに、もう一つ新しい機能を追加します。現状、投稿は少し退屈です。もっと魅力的にする必要がありますが、友達が投稿にリアクション絵文字を追加できるようにする以上の方法があるでしょうか?
<PostsList>
と<SinglePostPage>
の各投稿の下部に、絵文字リアクションボタンの列を追加します。ユーザーがリアクションボタンのいずれかをクリックするたびに、Reduxストア内のその投稿に対応するカウンターフィールドを更新する必要があります。リアクションカウンターデータはReduxストアにあるため、アプリの異なる部分間を切り替えても、そのデータを使用するコンポーネントでは常に同じ値が表示されます。
投稿の作者やタイムスタンプと同様に、投稿を表示するあらゆる場所でこれを使用したいと考えているため、post
をプロップとして受け取る<ReactionButtons>
コンポーネントを作成します。まず、現在の各ボタンのリアクションカウントとともに、ボタンを内部に表示することから始めます。
import React from 'react'
const reactionEmoji = {
thumbsUp: '👍',
hooray: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}
export const ReactionButtons = ({ post }) => {
const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
return (
<button key={name} type="button" className="muted-button reaction-button">
{emoji} {post.reactions[name]}
</button>
)
})
return <div>{reactionButtons}</div>
}
まだデータにpost.reactions
フィールドがないため、initialState
のpostオブジェクトとpostAdded
prepareコールバック関数を更新して、すべての投稿にreactions: {thumbsUp: 0, hooray: 0, heart: 0, rocket: 0, eyes: 0}
のようなデータが含まれるようにする必要があります。
これで、ユーザーがリアクションボタンをクリックしたときに投稿のリアクションカウントを更新する処理を行う新しいreducerを定義できます。
投稿の編集と同様に、投稿のIDと、ユーザーがクリックしたリアクションボタンを知る必要があります。action.payload
を{id, reaction}
のようなオブジェクトにします。reducerは、適切なpostオブジェクトを見つけ、正しいreactionsフィールドを更新できます。
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
// other reducers
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
既に見たように、createSlice
を使用すると、reducerで「変更」ロジックを記述できます。createSlice
とImmerライブラリを使用していない場合、existingPost.reactions[reaction]++
という行は、既存のpost.reactions
オブジェクトを変更します。これは、reducerのルールに従っていないため、アプリの他の場所でバグが発生する可能性があります。しかし、createSlice
を使用しているため、このより複雑な更新ロジックをより簡単な方法で記述し、Immerがこのコードを安全な不変の更新に変換する作業をさせることができます。
アクションオブジェクトには、発生したことを記述するために必要な最小限の情報のみが含まれています。更新が必要な投稿と、クリックされたリアクション名がわかります。新しいリアクションカウンター値を計算してアクションに追加することもできますが、アクションオブジェクトはできるだけ小さく保ち、ステート更新計算をreducerで行う方が常に優れています。これは、reducerに必要なだけのロジックを含めて新しいステートを計算できることも意味します。
最後のステップは、ユーザーがボタンをクリックしたときにreactionAdded
アクションをディスパッチするように<ReactionButtons>
コンポーネントを更新することです。
import React from 'react'
import { useDispatch } from 'react-redux'
import { reactionAdded } from './postsSlice'
const reactionEmoji = {
thumbsUp: '👍',
hooray: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}
export const ReactionButtons = ({ post }) => {
const dispatch = useDispatch()
const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
return (
<button
key={name}
type="button"
className="muted-button reaction-button"
onClick={() =>
dispatch(reactionAdded({ postId: post.id, reaction: name }))
}
>
{emoji} {post.reactions[name]}
</button>
)
})
return <div>{reactionButtons}</div>
}
これで、リアクションボタンをクリックするたびに、カウンターが増加します。アプリの異なる部分に移動した場合、<PostsList>
でリアクションボタンをクリックしてから<SinglePostPage>
で投稿を個別に確認しても、その投稿を確認するたびに正しいカウンター値が表示されます。
学んだこと
これらの変更後のアプリの様子を以下に示します。
実際にもっと便利で興味深いものになってきました!
このセクションでは、多くの情報と概念を網羅しました。覚えておくべき重要なことを要約しましょう。
- 任意のReactコンポーネントは、必要に応じてReduxストアのデータを使用できます。
- 任意のコンポーネントは、Reduxストアにある任意のデータを読み取ることができます。
- 複数のコンポーネントが、同時に同じデータを読み取ることができます。
- コンポーネントは、自分自身をレンダリングするために必要な最小限のデータを取り出す必要があります。
- コンポーネントは、プロップ、ステート、Reduxストアの値を組み合わせて、レンダリングする必要があるUIを決定できます。ストアから複数のデータを読み取り、必要に応じて表示用にデータを再整形できます。
- 任意のコンポーネントは、アクションをディスパッチしてステートの更新を引き起こすことができます。
- Reduxアクションクリエーターは、正しい内容のアクションオブジェクトを準備できます。
createSlice
とcreateAction
は、アクションペイロードを返す「prepareコールバック」を受け入れることができます。- 一意のIDやその他のランダムな値は、アクションに追加し、reducerで計算しないでください。
- reducerには、実際のステート更新ロジックを含める必要があります。
- reducerには、次のステートを計算するために必要なロジックを含めることができます。
- アクションオブジェクトには、発生したことを記述するのに十分な情報のみを含める必要があります。
次のステップ
これで、ReduxストアとReactコンポーネントでデータを使用できるようになりました。これまで、初期ステートに存在するデータやユーザーによって追加されたデータのみを使用してきました。パート5:非同期ロジックとデータ取得では、サーバーAPIから取得したデータの処理方法について説明します。