Redux Essentials パート4: Reduxデータの活用
このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →
- 複数のReactコンポーネントでのReduxデータの活用
- アクションをディスパッチするロジックの整理
- セレクターを使った状態値の検索
- リデューサーでのより複雑な更新ロジックの作成
- Reduxアクションの考え方
はじめに
パート3: Reduxデータフローの基礎では、空のRedux+Reactプロジェクト設定から始め、新しい状態スライスを追加し、Reduxストアからデータを読み取って更新アクションをディスパッチできるReactコンポーネントを作成する方法を見てきました。また、コンポーネントがアクションをディスパッチし、リデューサーがアクションを処理して新しい状態を返し、コンポーネントが新しい状態を読み取ってUIを再レンダリングするというアプリケーション内のデータフローも確認しました。さらに、正しいストアタイプが自動適用されたuseSelectorとuseDispatchフックの「事前型付け」バージョンを作成する方法も学びました。
Reduxロジック作成の核心的な手順を理解したので、これらの同じ手順を使ってソーシャルメディアフィードに新機能を追加します。具体的には、単一投稿の表示、既存投稿の編集、投稿者詳細の表示、投稿タイムスタンプ、リアクションボタン、認証機能などです。
補足として、コード例は各セクションの主要概念と変更点に焦点を当てています。アプリケーションの完全な変更内容については、CodeSandboxプロジェクトとプロジェクトリポジトリのtutorial-steps-tsブランチを参照してください。
単一投稿の表示
Reduxストアに新しい投稿を追加する機能ができたので、投稿データをさまざまな方法で活用する追加機能を実装できます。
現在、投稿エントリはメインフィードページに表示されていますが、テキストが長すぎる場合には内容の一部のみ表示されます。個別の投稿エントリを専用ページで表示できる機能があると便利でしょう。
単一投稿ページの作成
まず、posts機能フォルダに新しいSinglePostPageコンポーネントを追加する必要があります。ページURLが/posts/123のような形式の場合にこのコンポーネントを表示するようReact Routerを設定します。ここで123の部分は表示したい投稿のIDです。
import { useParams } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(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>
)
}
このコンポーネントをレンダリングするルートを設定する際、URLの2番目の部分をpostIdという変数として解析するように指定し、useParamsフックからその値を読み取ります。
postId値を取得したら、セレクター関数内でこの値を使用してReduxストアから適切な投稿オブジェクトを検索できます。state.postsはすべての投稿オブジェクトの配列であるはずなので、Array.find()関数を使用して配列をループし、探しているIDに一致する投稿エントリを返します。
重要な点として、useAppSelectorから返される値が新しい参照に変更されるたびにコンポーネントは再レンダリングされます。コンポーネントは常にストアから必要な最小限のデータのみを選択するように努めるべきであり、これにより実際に必要なときのみレンダリングされることが保証されます。
ストアに一致する投稿エントリがない可能性もあります(ユーザーが直接URLを入力した場合や、適切なデータが読み込まれていない場合など)。その場合、find()関数は実際の投稿オブジェクトではなくundefinedを返します。コンポーネントはこのケースをチェックし、ページに「投稿が見つかりません!」というメッセージを表示して対処する必要があります。
正しい投稿オブジェクトがストア内に存在する場合、useAppSelector はそのオブジェクトを返すため、ページ上で投稿のタイトルとコンテンツをレンダリングするのに使用できます。
このロジックは、メインフィードで投稿の抜粋を表示するためにposts配列全体をループ処理する<PostsList>コンポーネントの本体とかなり似ていることに気付くかもしれません。両方の場所で使用できるPostコンポーネントの抽出を試みることもできますが、投稿の抜粋表示と完全な投稿表示では既にいくつかの違いがあります。重複がある場合でも、しばらくはコードを個別に記述し続け、後でコンポーネントの抽出が本当に可能かどうかを判断する方が通常は良いでしょう。
個別投稿ページのルート追加
<SinglePostPage>コンポーネントができたので、表示用のルートを定義し、フロントページのフィードに各投稿へのリンクを追加できます。
ついでに、可読性向上のために「メインページ」のコンテンツを別の<PostsMainPage>コンポーネントとして抽出する価値もあります。
App.tsxでPostsMainPageとSinglePostPageをインポートし、ルートを追加します:
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
</Routes>
</div>
</Router>
)
}
export default App
次に、<PostsList>内でリストレンダリングロジックを更新し、特定の投稿へのルーティングを行う<Link>を含めます:
import { Link } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
export const PostsList = () => {
const posts = useAppSelector(state => state.posts)
const renderedPosts = posts.map(post => (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
</article>
))
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
)
}
別のページへのクリックが可能になったので、<Navbar>コンポーネントにメイン投稿ページへの戻りリンクも追加すると便利でしょう:
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>コンポーネントを追加しましょう。
投稿エントリの更新
まず、postsSliceを更新して、ストアが実際に投稿を更新する方法を知るための新しいreducer関数とactionを作成する必要があります。
createSlice()呼び出し内で、reducersオブジェクトに新しい関数を追加します。このreducerの名前は発生する内容を適切に説明するものであるべきです。なぜなら、このactionがdispatchされるたびに、reducer名がRedux DevToolsのaction type文字列の一部として表示されるからです。最初のreducerはpostAddedと呼ばれていたので、今回はpostUpdatedと呼びましょう。
Redux自体はこれらのreducer関数にどんな名前をつけるか気にしません - postAdded、addPost、POST_ADDED、someRandomNameのいずれでも同じように動作します。
ただし、reducerは「アプリケーションで発生したイベント」を説明するため、postAddedのような過去形の「何かが起こった」という名前をつけることを推奨します。
投稿オブジェクトを更新するには以下が必要です:
-
更新対象の投稿ID(状態内で正しい投稿オブジェクトを見つけるため)
-
ユーザーが入力した新しい
titleとcontentフィールド
Reduxのactionオブジェクトにはtypeフィールド(通常は説明文字列)が必須で、発生した内容に関する追加情報を含む他のフィールドがあっても構いません。慣例として、追加情報はaction.payloadフィールドに置きますが、payloadフィールドの内容は自由に決められます - 文字列、数値、オブジェクト、配列などです。この場合、3つの情報が必要なので、payloadフィールドにこれらを含むオブジェクトを置くことにします。つまり、actionオブジェクトは{type: 'posts/postUpdated', payload: {id, title, content}}のようになります。
デフォルトでは、createSliceによって生成されたアクションクリエーターは1つの引数を渡すことを想定しており、その値はaction.payloadとしてアクションオブジェクトに配置されます。したがって、これらのフィールドを含むオブジェクトをpostUpdatedアクションクリエーターの引数として渡すことができます。postAddedと同様に、これは完全なPostオブジェクトなので、レデューサーの引数をaction: PayloadAction<Post>と宣言します。
また、レデューサーはアクションがディスパッチされたときに状態を実際にどのように更新するかを決定する責任があることも理解しています。したがって、レデューサーはIDに基づいて適切な投稿オブジェクトを見つけ、その投稿のtitleとcontentフィールドを具体的に更新する必要があります。
最後に、createSliceが生成したアクションクリエーター関数をエクスポートする必要があります。これにより、ユーザーが投稿を保存したときにUIが新しいpostUpdatedアクションをディスパッチできるようになります。
これらの要件を踏まえると、完了後のpostsSlice定義は次のようになります:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
// omit state types
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
postUpdated(state, action: PayloadAction<Post>) {
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>と<SinglePostPage>の両方に似ていますが、ロジックは若干異なります。URLのpostIdに基づいてストアから適切なpostオブジェクトを取得し、それを使用してコンポーネントの入力フィールドを初期化し、ユーザーが変更できるようにする必要があります。ユーザーがフォームを送信すると、変更されたタイトルとコンテンツの値をストアに保存します。また、React RouterのuseNavigateフックを使用して、変更を保存した後は単一投稿ページに切り替えてその投稿を表示します。
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { postUpdated } from './postsSlice'
// omit form element types
export const EditPostForm = () => {
const { postId } = useParams()
const post = useAppSelector(state =>
state.posts.find(post => post.id === postId)
)
const dispatch = useAppDispatch()
const navigate = useNavigate()
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const onSavePostClicked = (e: React.FormEvent<EditPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
if (title && content) {
dispatch(postUpdated({ id: post.id, title, content }))
navigate(`/posts/${postId}`)
}
}
return (
<section>
<h2>Edit Post</h2>
<form onSubmit={onSavePostClicked}>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
defaultValue={post.title}
required
/>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue={post.content}
required
/>
<button>Save Post</button>
</form>
</section>
)
}
ここでのRedux固有のコードは比較的最小限であることに注意してください。再び、useAppSelectorを介してReduxストアから値を読み取り、ユーザーがUIを操作したときにuseAppDispatchを介してアクションをディスパッチします。
SinglePostPageと同様に、App.tsxにインポートし、postIdをルートパラメータとしてこのコンポーネントをレンダリングするルートを追加する必要があります。
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</div>
</Router>
)
}
export default App
また、SinglePostPageからEditPostFormへの新しいリンクを追加する必要があります:
import { Link, useParams } from 'react-router-dom'
export const SinglePostPage = () => {
// 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}のようなオブジェクトであることを確認する必要もあります。
現時点では、Reactコンポーネント内でIDを生成しペイロードオブジェクトを作成してから、そのペイロードオブジェクトをpostAddedに渡しています。しかし、異なるコンポーネントから同じアクションをディスパッチする必要がある場合や、ペイロードを準備するロジックが複雑な場合はどうでしょうか?その場合、アクションをディスパッチするたびにそのロジックを重複させなければならず、コンポーネントにこのアクションのペイロードがどのように見えるべきかを正確に認識させることになります。
アクションに一意のIDやその他のランダムな値が含まれる必要がある場合は、常に最初にそれを生成してアクションオブジェクトに配置してください。レデューサーがランダムな値を計算してはいけません。結果が予測不可能になるためです。
postAddedアクションクリエーターを手動で記述する場合、セットアップロジックを内部に含めることができました:
// hand-written action creator
function postAdded(title: string, content: string) {
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フィールド内で、次のような{reducer, prepare}形式のオブジェクトとしてフィールドを定義できます:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string) {
return {
payload: { id: nanoid(), title, content }
}
}
}
// other reducers here
}
})
これでコンポーネントはペイロードオブジェクトの構造を気にする必要がなくなり、アクションクリエイターが適切な形式で組み立てる処理を肩代わりしてくれます。したがって、postAddedをディスパッチする際にtitleとcontentを引数として渡すようにコンポーネントを更新できます:
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Now we can pass these in as separate arguments,
// and the ID will be generated automatically
dispatch(postAdded(title, content))
e.currentTarget.reset()
}
セレクターを使ったデータ取得
現在、複数のコンポーネントがIDで投稿を検索するためにstate.posts.find()を呼び出しており、これは重複コードです。このような場合は常に重複解消を検討する価値があります。またこの方法は脆い(fragile)実装でもあります。後述するように、投稿スライスの状態構造は将来的に変更される予定です。その際、state.postsを参照している箇所をすべて見つけ出し、ロジックを更新する必要が生じます。TypeScriptはコンパイル時に期待される状態タイプと一致しない壊れたコードをエラーとして検出できますが、リデューサーのデータ形式を変更するたびにコンポーネントを書き直したり、コンポーネント内でロジックを重複させたりするのは避けたいところです。
これを回避する方法の一つは、スライスファイル内で再利用可能なセレクター関数を定義し、各コンポーネントでセレクターロジックを重複させる代わりに、これらのセレクターを使用して必要なデータを抽出させることです。こうすれば状態構造を変更する場合も、スライスファイル内のコードだけを更新すればよくなります。
セレクター関数の定義
これまでuseAppSelector( state => state.posts )のようにuseAppSelectorを呼び出すたびにセレクター関数を記述してきました。この場合、セレクターはインラインで定義されています。単なる関数なので、次のように独立して記述することも可能です:
const selectPosts = (state: RootState) => state.posts
const posts = useAppSelector(selectPosts)
セレクターは通常、スライスファイル内で独立した個別の関数として記述されます。第一引数にはReduxのルート状態(RootState)全体を受け取り、追加の引数を持つこともあります。
投稿セレクターの抽出
<PostsList>コンポーネントはすべての投稿のリストを取得する必要があり、<SinglePostPage>と<EditPostForm>コンポーネントはIDで単一の投稿を検索する必要があります。これらのケースに対応するために、postsSlice.tsから2つの小さなセレクター関数をエクスポートしましょう:
import type { RootState } from '@/app/store'
const postsSlice = createSlice(/* omit slice code*/)
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = (state: RootState) => state.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.find(post => post.id === postId)
これらのセレクター関数のstateパラメーターは、useAppSelector内に直接記述したインラインの無名セレクターと同様に、Reduxのルート状態オブジェクトです。
コンポーネント内では次のように使用できます:
// omit imports
import { selectAllPosts } from './postsSlice'
export const PostsList = () => {
const posts = useAppSelector(selectAllPosts)
// omit component contents
}
// omit imports
import { selectPostById } from './postsSlice'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
// omit imports
import { postUpdated, selectPostById } from './postsSlice'
export const EditPostForm = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
useParams()から取得するpostIdはstring | undefined型ですが、selectPostByIdは有効なstring型の引数を期待している点に注意してください。TSコンパイラにこの時点で値がundefinedではないことを伝えるために、TSの!演算子を使用できます(これは危険な可能性がありますが、ルーティング設定が投稿IDがURLにある場合にのみ<EditPostForm>を表示することを知っているので、この仮定を置けます)。
今後も、コンポーネント内のuseAppSelectorにインラインで記述する代わりに、スライス内でセレクターを記述するこのパターンを継続します。必須ではありませんが、従うべき良いパターンです!
セレクターの効果的な活用
再利用可能なセレクターを作成してデータ検索をカプセル化することは、多くの場合良いアイデアです。理想的には、コンポーネントがReduxのstate内のどこに値が存在するかさえ知る必要がなく、スライスからセレクターをインポートしてデータにアクセスするだけになります。
また、後ほどこのチュートリアルで詳しく説明しますが、「メモ化された」セレクターを作成することで、再レンダリングの最適化や不要な再計算のスキップによるパフォーマンス向上も可能です。
ただし、あらゆる抽象化と同様に、これは常にどこでも行うべきものではありません。セレクターを書くことは、理解し維持すべきコードが増えることを意味します。状態のすべてのフィールドに対してセレクターを書く必要はありません。最初はセレクターなしで始め、アプリケーションコードの多くの部分で同じ値を参照していることに気づいたときに追加するようにしてください。
オプション: createSlice内でのセレクター定義
これまで、スライスファイル内でスタンドアロンの関数としてセレクターを書く方法を見てきました。場合によっては、createSlice内に直接セレクターを定義することで少し短縮できます。
Defining Selectors inside createSlice
We've already seen that createSlice requires the name, initialState, and reducers fields, and also accepts an optional extraReducers field.
If you want to define selectors directly inside of createSlice, you can pass in an additional selectors field. The selectors field should be an object similar to reducers, where the keys will be the selector function names, and the values are the selector functions to be generated.
Note that unlike writing a standalone selector function, the state argument to these selectors will be just the slice state, and not the entire RootState!.
Here's what it might look like to convert the posts slice selectors to be defined inside of createSlice:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
/* omit reducer logic */
},
selectors: {
// Note that these selectors are given just the `PostsState`
// as an argument, not the entire `RootState`
selectAllPosts: postsState => postsState,
selectPostById: (postsState, postId: string) => {
return postsState.find(post => post.id === postId)
}
}
})
export const { selectAllPosts, selectPostById } = postsSlice.selectors
export default postsSlice.reducer
// We've replaced these standalone selectors:
// export const selectAllPosts = (state: RootState) => state.posts
// export const selectPostById = (state: RootState, postId: string) =>
// state.posts.find(post => post.id === postId)
There are still times you'll need to write selectors as standalone functions outside of createSlice. This is especially true if you're calling other selectors that need the entire RootState as their argument, in order to make sure the types match up correctly.
ユーザーと投稿
これまで、私たちは単一の状態スライスしか持っていませんでした。ロジックはpostsSlice.tsで定義され、データはstate.postsに保存され、すべてのコンポーネントは投稿機能に関連していました。実際のアプリケーションでは、おそらく多くの異なる状態スライスと、ReduxロジックとReactコンポーネントのためのいくつかの異なる「機能フォルダ」が存在するでしょう。
他の人々が関与していなければ「ソーシャルメディア」アプリは成り立ちません!アプリ内のユーザーリストを追跡する機能を追加し、そのデータを活用するように投稿関連の機能を更新しましょう。
ユーザースライスの追加
「ユーザー」の概念は「投稿」の概念とは異なるため、ユーザーのコードとデータは投稿のものから分離して保持したいと考えます。新しいfeatures/usersフォルダを追加し、そこにusersSliceファイルを配置します。投稿スライスと同様に、作業するためのデータを用意するために初期エントリをいくつか追加します。
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '@/app/store'
interface User {
id: string
name: string
}
const initialState: User[] = [
{ 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
export const selectAllUsers = (state: RootState) => state.users
export const selectUserById = (state: RootState, userId: string | null) =>
state.users.find(user => user.id === userId)
現時点では、実際にデータを更新する必要がないため、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
}
})
これで、ルートステートは{posts, users}のようになり、reducer引数として渡したオブジェクトと一致します。
投稿への著者の追加
アプリ内のすべての投稿はユーザーのいずれかによって書かれており、新しい投稿を追加するたびに、どのユーザーがその投稿を書いたかを追跡する必要があります。これにはRedux状態と<AddPostForm>コンポーネントの両方への変更が必要です。
まず、既存のPostデータ型を更新して、投稿を作成したユーザーIDを含むuser: stringフィールドを追加する必要があります。また、initialState内の既存の投稿エントリを更新し、サンプルユーザーIDのいずれかを持つpost.userフィールドを含めます。
次に、既存のリデューサーをそれに応じて更新する必要があります。postAddedのprepareコールバックはユーザーIDを引数として受け入れ、それをアクションに含める必要があります。また、投稿を更新する際にはuserフィールドを含めないようにします。必要なのは、変更された投稿のidと、更新されたテキストの新しいtitleおよびcontentフィールドだけです。Postからこれら3つのフィールドだけを含むPostUpdate型を定義し、postUpdatedのペイロードとして代わりに使用します。
export interface Post {
id: string
title: string
content: string
user: string
}
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
const initialState: Post[] = [
{ id: '1', title: 'First Post!', content: 'Hello!', user: '0' },
{ id: '2', title: 'Second Post', content: 'More text', user: '2' }
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId
}
}
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})
これで、<AddPostForm>内でuseSelectorを使用してストアからユーザーリストを読み取り、ドロップダウンとして表示できます。その後、選択したユーザーのIDを取得し、postAddedアクションクリエーターに渡します。ついでに、タイトルとコンテンツの入力に実際のテキストが含まれている場合にのみユーザーが「投稿を保存」ボタンをクリックできるように、フォームに検証ロジックを追加しましょう:
import { selectAllUsers } from '@/features/users/usersSlice'
// omit other imports and form types
const AddPostForm = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const userId = elements.postAuthor.value
dispatch(postAdded(title, content, userId))
e.currentTarget.reset()
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" defaultValue="" required />
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" name="postAuthor" required>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button>Save Post</button>
</form>
</section>
)
}
次に、投稿リストアイテムと<SinglePostPage>内で投稿者の名前を表示する方法が必要です。同じ情報を複数の場所で表示したいため、ユーザーIDをpropsとして受け取り、適切なユーザーオブジェクトを検索してユーザー名を整形するPostAuthorコンポーネントを作成します:
import { useAppSelector } from '@/app/hooks'
import { selectUserById } from '@/features/users/usersSlice'
interface PostAuthorProps {
userId: string
}
export const PostAuthor = ({ userId }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))
return <span>by {author?.name ?? 'Unknown author'}</span>
}
各コンポーネントで同じパターンに従っていることに注目してください。Reduxストアからデータを読み取る必要があるコンポーネントはすべてuseAppSelectorフックを使用し、必要な特定のデータ部分を抽出できます。また、複数のコンポーネントが同時にReduxストア内の同じデータにアクセス可能です。
PostAuthorコンポーネントをPostsList.tsxとSinglePostPage.tsxの両方にインポートし、<PostAuthor userId={post.user} />としてレンダリングできます。投稿を追加するたびに、選択したユーザーの名前がレンダリングされた投稿内に表示されるようになります。
投稿機能の拡張
これで投稿の作成と編集が可能になりました。投稿フィードをより有用にするために追加ロジックを実装しましょう。
投稿日時の保存
ソーシャルメディアのフィードは通常、投稿作成日時でソートされ、「5時間前」のような相対表現で投稿時間を表示します。これを実現するには、投稿エントリー用にdateフィールドの追跡を開始する必要があります。
post.userフィールドと同様に、アクションがディスパッチされる際に常にpost.dateが含まれるようpostAddedのprepareコールバックを更新します。ただし、これは渡されるパラメータではありません。アクションがディスパッチされた正確なタイムスタンプを使用したいため、prepareコールバック自身に処理させます。
Reduxアクションとステートにはオブジェクト、配列、プリミティブ値などのプレーンなJS値のみを含めてください。クラスインスタンス、関数、Date/Map/Setインスタンス、その他の非シリアライズ可能な値をReduxに格納しないでください!
DateクラスインスタンスをReduxストアに直接格納できないため、post.date値をタイムスタンプ文字列として追跡します。初期ステート値に追加し(現在の日時から数分を減算するためにdate-fnsを使用)、prepareコールバック内の各新規投稿にも追加します。
import { createSlice, nanoid } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
const initialState: Post[] = [
{
// omitted fields
content: 'Hello!',
date: sub(new Date(), { minutes: 10 }).toISOString()
},
{
// omitted fields
content: 'More text',
date: sub(new Date(), { minutes: 5 }).toISOString()
}
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId
}
}
}
}
// omit `postUpdated
}
})
投稿作成者と同様に、<PostsList>と<SinglePostPage>の両コンポーネントで相対的なタイムスタンプ表記を表示する必要があります。タイムスタンプ文字列を相対表現として整形する<TimeAgo>コンポーネントを追加します。date-fnsのようなライブラリには日付の解析と整形に有用なユーティリティ関数があります:
import { parseISO, formatDistanceToNow } from 'date-fns'
interface TimeAgoProps {
timestamp: string
}
export const TimeAgo = ({ timestamp }: TimeAgoProps) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}
return (
<time dateTime={timestamp} title={timestamp}>
<i>{timeAgo}</i>
</time>
)
}
投稿リストのソート
現在の<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 (
// omit rendering logic
)
})
投稿リアクションボタン
現在、投稿は少し退屈です。もっと面白くする必要があり、友達が投稿に絵文字リアクションを追加できるようにするのはどうでしょう? 🎉
<PostsList>と<SinglePostPage>の各投稿の下部に絵文字リアクションボタンの行を追加します。ユーザーがリアクションボタンをクリックするたびに、Reduxストア内の該当投稿に対応するカウンターフィールドを更新する必要があります。リアクションカウンターのデータはReduxストア内にあるため、アプリの異なる部分を切り替えても、そのデータを使用するあらゆるコンポーネントで一貫した値が表示されます。
投稿データでのリアクションの追跡
現在のデータにはpost.reactionsフィールドが存在しないため、initialStateの投稿オブジェクトとpostAddedのprepareコールバック関数を更新し、すべての投稿がreactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0}のようなデータを持つようにする必要があります。
次に、ユーザーがリアクションボタンをクリックした際に投稿のリアクションカウントを更新する新しいリデューサーを定義します。
投稿の編集と同様に、投稿IDとユーザーがクリックしたリアクションボタンを特定する必要があります。action.payloadを{id, reaction}形式のオブジェクトにします。リデューサーは該当する投稿オブジェクトを見つけ、適切なリアクションフィールドを更新できます。
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
export interface Reactions {
thumbsUp: number
tada: number
heart: number
rocket: number
eyes: number
}
export type ReactionName = keyof Reactions
export interface Post {
id: string
title: string
content: string
user: string
date: string
reactions: Reactions
}
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
const initialReactions: Reactions = {
thumbsUp: 0,
tada: 0,
heart: 0,
rocket: 0,
eyes: 0
}
const initialState: Post[] = [
// omit initial state
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit other reducers
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
これまで見てきたように、createSliceを使用するとリデューサー内で「変更」ロジックを記述できます。もしcreateSliceとImmerライブラリを使用していなければ、existingPost.reactions[reaction]++という行は確かに既存のpost.reactionsオブジェクトを変更し、リデューサーのルールに従わなかったためにアプリの他の部分でバグを引き起こす可能性があります。しかし、createSliceを使用しているため、この複雑な更新ロジックをより簡単に記述でき、Immerがこのコードを安全な不変更新に変換します。
アクションオブジェクトには発生した事象を説明する最小限の情報のみが含まれていることに注目してください。どの投稿を更新する必要があるか、どのリアクション名がクリックされたかがわかります。新しいリアクションカウンター値を計算してアクションに含めることもできましたが、アクションオブジェクトは可能な限り小さく保ち、状態更新の計算はリデューサー内で行う方が常に優れています。これはまた、リデューサーには新しい状態を計算するために必要なだけのロジックを含められることを意味します。実際、状態更新ロジックはリデューサー内にあるべきです!これにより、異なるコンポーネントでロジックが重複する問題や、UI層が最新のデータを持っていない場合の問題を回避できます。
リアクションボタンの表示
投稿の作成者やタイムスタンプと同様に、投稿を表示するあらゆる場所でこれを使用したいので、postをプロップとして受け取る<ReactionButtons>コンポーネントを作成します。ユーザーがボタンをクリックすると、そのリアクション絵文字の名前を含むreactionAddedアクションをディスパッチします。
import { useAppDispatch } from '@/app/hooks'
import type { Post, ReactionName } from './postsSlice'
import { reactionAdded } from './postsSlice'
const reactionEmoji: Record<ReactionName, string> = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}
interface ReactionButtonsProps {
post: Post
}
export const ReactionButtons = ({ post }: ReactionButtonsProps) => {
const dispatch = useAppDispatch()
const reactionButtons = Object.entries(reactionEmoji).map(
([stringName, emoji]) => {
// Ensure TS knows this is a _specific_ string type
const reaction = stringName as ReactionName
return (
<button
key={reaction}
type="button"
className="muted-button reaction-button"
onClick={() => dispatch(reactionAdded({ postId: post.id, reaction }))}
>
{emoji} {post.reactions[reaction]}
</button>
)
}
)
return <div>{reactionButtons}</div>
}
これで、リアクションボタンをクリックするたびに、そのリアクションのカウンターが増加するはずです。アプリの異なる部分を閲覧すると、たとえ<PostsList>でリアクションボタンをクリックした後、<SinglePostPage>で投稿を単独で表示しても、常に正しいカウンター値が表示されます。これは各コンポーネントがReduxストアから同じ投稿データを読み取っているためです。
ユーザーログインの追加
このセクションで追加する最後の機能があります。
現在、各投稿の作成者は<AddPostForm>で選択していますが、より現実的にするために、ユーザーにアプリケーションへのログインを要求すべきです(これにより投稿者があらかじめ分かり、後で他の機能にも役立ちます)。
これは小さなサンプルアプリのため、実際の認証チェックは実装しません(ここでのポイントは実際の認証実装ではなく、Redux機能の使い方を学ぶことです)。代わりにユーザー名リストを表示し、実際のユーザーに選択してもらいます。
この例では、state.auth.usernameを追跡するauthスライスを追加し、ユーザーを識別できるようにします。その後、投稿を追加する際にこの情報を使用して、適切なユーザーIDを自動的に付与します。
認証スライスの追加
最初のステップはauthSliceを作成しストアに追加することです。これまで見てきたパターンと同じです:初期状態を定義、ログイン/ログアウト処理用のリデューサーを含むスライスを作成し、スライスリデューサーをストアに追加します。
このケースでは、認証状態は実質的に現在ログイン中のユーザー名であり、ログアウト時にはnullにリセットします。
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface AuthState {
username: string | null
}
const initialState: AuthState = {
// Note: a real app would probably have more complex auth state,
// but for this example we'll keep things simple
username: null
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
userLoggedIn(state, action: PayloadAction<string>) {
state.username = action.payload
},
userLoggedOut(state) {
state.username = null
}
}
})
export const { userLoggedIn, userLoggedOut } = authSlice.actions
export const selectCurrentUsername = (state: RootState) => state.auth.username
export default authSlice.reducer
import { configureStore } from '@reduxjs/toolkit'
import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer
}
})
ログインページの追加
現在、アプリのメイン画面は投稿リストと投稿フォームを持つ<Posts>コンポーネントです。この挙動を変更します。代わりに、ユーザーには最初にログイン画面を表示し、ログイン後にのみ投稿ページを閲覧可能にします。
まず<LoginPage>コンポーネントを作成します。ストアからユーザーリストを読み取り、ドロップダウンで表示し、フォーム送信時にuserLoggedInアクションをディスパッチします。ログイン後は/postsルートにナビゲートし<PostsMainPage>を表示します:
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'
import { userLoggedIn } from './authSlice'
interface LoginPageFormFields extends HTMLFormControlsCollection {
username: HTMLSelectElement
}
interface LoginPageFormElements extends HTMLFormElement {
readonly elements: LoginPageFormFields
}
export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()
const handleSubmit = (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()
const username = e.currentTarget.elements.username.value
dispatch(userLoggedIn(username))
navigate('/posts')
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Welcome to Tweeter!</h2>
<h3>Please log in:</h3>
<form onSubmit={handleSubmit}>
<label htmlFor="username">User:</label>
<select id="username" name="username" required>
<option value=""></option>
{usersOptions}
</select>
<button>Log In</button>
</form>
</section>
)
}
次に<App>コンポーネントのルーティングを更新します。ルート/では<LoginPage>を表示し、未認証アクセス時にはログイン画面にリダイレクトする必要があります。
一般的な方法は、Reactコンポーネントをchildrenとして受け取り、認証チェック後に子コンポーネントを表示する「保護されたルート」コンポーネントを追加することです。state.auth.username値を読み取る<ProtectedRoute>コンポーネントを作成し、ルーティング設定の投稿関連セクション全体をその <ProtectedRoute> でラップします:
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'
import { useAppSelector } from './app/hooks'
import { Navbar } from './components/Navbar'
import { LoginPage } from './features/auth/LoginPage'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'
import { selectCurrentUsername } from './features/auth/authSlice'
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const username = useAppSelector(selectCurrentUsername)
if (!username) {
return <Navigate to="/" replace />
}
return children
}
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</ProtectedRoute>
}
/>
</Routes>
</div>
</Router>
)
}
export default App
これで認証挙動の両面が機能します:
-
ログインせずに
/postsにアクセスしようとすると、<ProtectedRoute>コンポーネントが/にリダイレクトし<LoginPage>を表示します -
ユーザーがログインすると、
userLoggedIn()をディスパッチしてRedux状態を更新し、/postsに強制ナビゲートします。この時点で<ProtectedRoute>が投稿ページを表示します。
現在のユーザーでUIを更新
アプリ使用中に誰がログインしているかわかるので、ナビゲーションバーに実際のユーザー名を表示できます。「ログアウト」ボタンを追加してログアウト手段も提供します。
表示用にuser.nameを読み取るため、ストアから現在のユーザーオブジェクトを取得する必要があります。まずauthスライスから現在のユーザー名を取得し、それを使用して適切なユーザーオブジェクトを検索します。これは複数箇所で使用する可能性があるため、再利用可能なselectCurrentUserセレクターとして作成する良い機会です。usersSlice.tsに配置できますが、authSlice.tsからselectCurrentUsernameをインポートして依存させます:
import { selectCurrentUsername } from '@/features/auth/authSlice'
// omit the rest of the slice and selectors
export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
return selectUserById(state, currentUsername)
}
セレクターを組み合わせて、別のセレクター内で使用することはよくあります。このケースではselectCurrentUsernameとselectUserByIdの両方を使用できます。
これまで構築した他の機能と同様に、ストアから関連状態(現在のユーザーオブジェクト)を選択し、値を表示し、「ログアウト」ボタンクリック時にuserLoggedOut()アクションをディスパッチします:
import { Link } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { userLoggedOut } from '@/features/auth/authSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'
import { UserIcon } from './UserIcon'
export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(userLoggedOut())
}
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)
}
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
{navContent}
</section>
</nav>
)
}
ついでに、<AddPostForm> コンポーネントを修正し、ユーザー選択ドロップダウンの代わりにステートからログイン済みのユーザー名を使用するようにしましょう。これを行うには、postAuthor 入力フィールドに関するすべての参照を削除し、useAppSelector を追加して authSlice からユーザー ID を取得します:
export const AddPostForm = () => {
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Removed the `postAuthor` field everywhere in the component
dispatch(postAdded(title, content, userId))
e.currentTarget.reset()
}
最後に、現在のユーザーが他のユーザーが作成した投稿を編集できるのは不適切です。投稿者の ID が現在のユーザー ID と一致する場合のみ「投稿を編集」ボタンを表示するよう <SinglePostPage> を更新できます:
import { selectCurrentUsername } from '@/features/auth/authSlice'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
const currentUsername = useAppSelector(selectCurrentUsername)!
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const canEdit = currentUsername === post.user
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content}</p>
<ReactionButtons post={post} />
{canEdit && (
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
)}
</article>
</section>
)
}
ログアウト時の他の状態のクリア
認証処理に関して考慮すべき点がもう一つあります。現在、ユーザーAとしてログインして新しい投稿を作成し、ログアウト後、ユーザーBとして再度ログインすると、初期のサンプル投稿と新しい投稿の両方が表示されます。
これはこれまでに記述したコードではReduxが意図通り動作しているという意味で「正しい」動作です。Reduxストア内の投稿リストの状態を更新し、ページをリフレッシュしていないため、同じJavaScriptデータがメモリ内に残っています。しかしアプリの動作としては混乱を招き、プライバシー侵害の可能性すらあります。ユーザーBとユーザーAが互いに接続されていない場合はどうなるでしょうか?複数の人が同じコンピューターを共有している場合は?ログイン時に互いのデータを見られるべきではありません。
したがって、現在のユーザーがログアウトしたときに既存の投稿状態をクリアできると良いでしょう。
複数スライスでのアクション処理
これまでは、別の状態更新を行う必要があるたびに新しいReduxケースリデューサーを定義し、生成されたアクションクリエーターをエクスポートし、コンポーネントからそのアクションをディスパッチしてきました。ここでも同じ方法を_取ることはできます_が、結果的に次のように2つの別々のReduxアクションを連続してディスパッチすることになります:
dispatch(userLoggedOut())
// This seems like it's duplicate behavior
dispatch(clearUserData())
アクションをディスパッチするたびに、リデューサーの実行、購読済みUIコンポーネントへの通知、更新されたコンポーネントの再レンダリングといったReduxストア更新プロセス全体が発生します。これはReduxとReactの通常の動作であり問題ありませんが、2つのアクションを連続してディスパッチすることは通常、ロジックの定義方法を見直す必要がある兆候です。
既に userLoggedOut() アクションがディスパッチされていますが、これは auth スライスからエクスポートされたアクションです。このアクションを posts スライスでもリッスンできれば理想的です。
以前、アクションを「値を設定するためのコマンド」ではなく「アプリで発生したイベント」として考えると役立つと説明しました。これはその考え方を実践する良い例です。発生したイベントは「ユーザーのログアウト」ただ一つであり、clearUserData 用の別アクションは_必要ありません_。必要なのは、一つの userLoggedOut アクションを複数箇所で処理する方法であり、それにより関連するすべての状態更新を同時に適用できます。
extraReducers を使用して他のアクションを処理する
幸いなことに、これが可能です!createSlice は extraReducers というオプションを受け付け、これを使用するとスライスがアプリの他の場所で定義されたアクションをリッスンできるようになります。これらのアクションがディスパッチされるたびに、このスライスも自身の状態を更新できます。つまり、_複数_の異なるスライスリデューサーが同じディスパッチされたアクションに_すべて_応答し、各スライスが必要に応じて自身の状態を更新できるのです!
extraReducers フィールドは builder という名前のパラメーターを受け取る関数です。builder オブジェクトには3つのメソッドが付属しており、各メソッドはスライスが他のアクションをリッスンし自身の状態更新を行うことを可能にします:
-
builder.addCase(actionCreator, caseReducer):特定のアクションタイプをリッスンする -
builder.addMatcher(matcherFunction, caseReducer):複数のアクションタイプのいずれかをリッスンする(Redux Toolkitの「matcher」関数を使用してアクションオブジェクトを比較) -
builder.addDefaultCase(caseReducer): このスライス内で他のアクションに一致しない場合に実行されるケースリデューサーを追加します(switch文内のdefaultケースと同等)。
これらはbuilder.addCase().addCase().addMatcher().addDefaultCase()のようにチェーンできます。複数のマッチャーがアクションに一致する場合、定義順に実行されます。
この仕組みを活用し、authSlice.tsからuserLoggedOutアクションをpostsSlice.tsにインポートし、postsSlice.extraReducers内でこのアクションをリッスンして、ログアウト時に投稿リストをリセットするために空の投稿配列を返します:
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
import { userLoggedOut } from '@/features/auth/authSlice'
// omit initial state and types
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
// omit postAdded and other case reducers
},
extraReducers: (builder) => {
// Pass the action creator to `builder.addCase()`
builder.addCase(userLoggedOut, (state) => {
// Clear out the list of posts whenever the user logs out
return []
})
},
})
builder.addCase(userLoggedOut, caseReducer)を呼び出します。このリデューサー内では、他のcreateSlice呼び出し内のケースリデューサーと同様に「変更」を行う状態更新を記述できます。しかし、既存の状態を完全に置き換えたい場合、最も単純な方法は新しい投稿状態として空の配列を返すことです。
これで「ログアウト」ボタンをクリック後、別ユーザーとしてログインすると「投稿」ページが空になります。素晴らしい!ログアウト時に投稿状態を正常にクリアできました。
reducersとextraReducersの違いとは?createSlice内のreducersフィールドとextraReducersフィールドは異なる目的で使用されます:
reducersフィールドは通常オブジェクトです。reducersオブジェクト内で定義された各ケースリデューサーに対して、createSliceは自動的に同名のアクションクリエーターとRedux DevToolsに表示するアクションタイプ文字列を生成します。新しいアクションをスライスの一部として定義する場合はreducersを使用してください。extraReducersはbuilderパラメータを持つ関数を受け入れ、builder.addCase()およびbuilder.addMatcher()メソッドを使用して新しいアクションを定義せずに他のアクションタイプを処理します。スライスの外部で定義されたアクションを処理する場合はextraReducersを使用してください。
学んだこと
これでこのセクションは終了です!多くの作業を行いました。個別の投稿を表示・編集できるようになり、各投稿の作成者を確認でき、絵文字リアクションを追加でき、現在のユーザーのログイン・ログアウトを追跡できます。
すべての変更を適用後のアプリケーションは以下のようになります:
実際にさらに有用で興味深い見た目になりました!
このセクションでは多くの情報と概念をカバーしました。重要なポイントを振り返りましょう:
- 任意のReactコンポーネントは必要に応じてReduxストアのデータを利用できる
- すべてのコンポーネントはReduxストア内の任意のデータを読み取れる
- 複数のコンポーネントが同時に同じデータを読み取ることが可能
- コンポーネントは自身をレンダリングするのに必要な最小限のデータのみを抽出すべき
- コンポーネントはprops、state、Reduxストアの値を組み合わせて表示すべきUIを決定できる。複数のデータをストアから読み取り、表示用に再整形可能
- どのコンポーネントも状態更新を促すアクションをディスパッチできる
- Reduxアクションクリエーターは適切な内容のアクションオブジェクトを準備できる
createSliceとcreateActionはアクションペイロードを返す「準備コールバック」を受け入れ可能- ユニークIDやランダム値はリデューサー内で計算せず、アクションに含めるべき
- リデューサーには実際の状態更新ロジックを含めるべき
- リデューサーは次状態の計算に必要なあらゆるロジックを含められる
- アクションオブジェクトは発生した事象を記述するのに十分な情報のみを含めるべき
- Redux状態から値を読み取るために再利用可能な「セレクター」関数を作成できる
- セレクターはReduxの
stateを引数として受け取り、データを返す関数
- セレクターはReduxの
- アクションは「発生したイベント」として捉え、複数のリデューサーが同じディスパッチされたアクションに反応できる
- アプリケーションは通常、一度に1つのアクションのみをディスパッチすべき
- ケースリデューサーの名前(およびアクション)は通常、
postAddedのように過去形で命名すべき - 多くのスライスリデューサーが同じアクションに応答して各自の状態更新を実行可能
createSlice.extraReducersによりスライス外部で定義されたアクションを監視可能- 状態値は既存状態を変更する代わりに、ケースリデューサーから置換用の新しい値を返すことでリセット可能
次のステップ
現時点で、ReduxストアとReactコンポーネントでのデータ操作に慣れてきたはずです。これまで初期状態にあったデータやユーザーが追加したデータのみを使用してきました。パート5:非同期ロジックとデータ取得では、サーバーAPIから取得するデータの操作方法を学びます。