Using Redux Data
どうも、ぽっぽです。
Reduxの公式ドキュメントを読んで理解したことを紹介します。
今まで学んだ知識をもとに、より実践的なサンプルアプリケーションを作成してより深くReduxのことを学んでいきましょう。
Redux logicを記述するための中心的なステップについて知っておきましょう。これからいくつかのソーシャルメディアプリケーションの仕様を今までと同じステップで追加していきます。1記事の投稿を表示、既存の投稿記事の編集、投稿者の詳細表示、投稿日付、そして、リアクションボタンです。
Showing Single Post
Redux store に新規投稿を追加する機能は作りました。異なった方法で投稿データを利用するいくつかの仕様を追加しましょう。
Creating a Single Post Page
最初にposts
フォルダにSinglePostPage
コンポーネントを追加しましょう。
features/posts/SinglePsotPage.js
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はpropとして、探しているURL情報を含んでいるオブジェクトを渡してくれます。このコンポーネントを表示するためにrouteをセットアップするとき、postId
という名前の変数としてURLの2番目をパースして教えてくれます。match.params
から値を取得できます。
postID
の値を内部のselector関数に渡してRedux storeからpostオブジェクトを見つけることが出来ます。state.posts
はすべてのpostオブジェクトの配列です。だからArray.find()
関数を使って配列をループして探したいIDを持つpost entryを返すことが出来ます。
重要なことはuseSelector
から値が帰るときはいつでもコンポーネントを再描画します。コンポーネントはstoreから必要なデータを限りなく小さくすべきです。
Adding the Single Post Route
<SinglePostPage>
コンポーネントができたので、それを表示するためにrouteを定義して、ページフィードにそれぞれのpostへのリンクを追加しましょう。
App.js
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>
に任意のpostをルートする<Link>
を含んだ再描画するリストに更新しましょう。
features/posts/PostsList.js
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>
コンポーネントにもメインの投稿ページに戻るリンクをつけましょう。
app/Navbar.js
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>
)
}
Editing Posts
記事を投稿した後に編集する機能をつけましょう。
新しく<EditPostForm>
コンポーネントを追加します。存在するpost IDを使ってstoreから記事を読みだし、ユーザにタイトルとコンテンツを編集できるようにします。そして、storeに記事を更新して保存します。
Updating Post Entries
最初は実際にどうやってpostsを更新するかstoreに知らせるために新しいreducer関数とactionを作成しpostsSlice
を更新する必要があります。
createSlice()
の内側に、reducers
オブジェクトに新しい関数を追加します。思い出してほしいことは、reducerの名前は何が起こるかが分かる記述にすることです。actionがディスパッチされるときはいつでもRedux DevToolsでactionのtype文字列の一部としてreducerの名前が表示されるからです。
Redux actionオブジェクトは普通記述的な文字列となるtype
フィールドが必要です。また、何が起きたかのより多くの情報となる他のフィールドを含むかもしれません。追加情報はaction.payload
と呼ばれるフィールドです。payloadフィールドに何が含まれるかは私達次第です。今回の場合、3つの情報が必要です。3つのフィールドを持つオブジェクトとしてpayload
フィールドを持ちましょう。actionオブジェクトは{type:'posts/postUpdated', payload:{id, title, content}}
のようになるでしょう。
createSlice
によって生成されるaction creatorsは1つの引数を渡します。その値はaction.payload
としてactionオブジェクトに入れられます。
reducerはactionがディスパッチしたときどうやってstateが更新されるかを決定します。
最後にcreateSlice
が生成したaction creator関数をエクスポートします。UIは新しいpostUpdated
actionをユーザが記事をセーブしたときディスパッチすることができます。
features/posts/postsSlice.js
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
Creatin an Edit Post Form
新しい<EditPostForm>
コンポーネントは<AddPostForm>
に似ています。でもロジックは少し違う必要があります。storeから投稿オブジェクトを呼び出す必要があります。そしてコンポーネントにユーザが変更できるようにstateフィールドを初期化するために使われます。
features/posts/EditPostForm.js
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>
)
}
EditPostPage
へのルートをSinblePostPage
に新しいリンクを追加します。
features/post/SinglePostPage.js
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>
Preparing Action Payloads
今まで見てきたようにcreateSlice
から出来たaction creatorsは普通はaction.payload
のように1つの引数を受け取ります。これはもっとも一般的な使い方のパターンを簡単にしています。でも時々actionオブジェクトのコンテンツを準備して実行する必要があります。postAdded
actionの場合、新規投稿用のユニークIDを生成させる必要があります。そしてまた、payloadが{id, title, content}
のようなオブジェクトである必要があります。
現在は、Reactコンポーネントでpayloadオブジェクトを作ってIDを生成してpostAded
に渡しています。しかし、異なるコンポーネントから同じactionをディスパッチする必要があったらpayloadを準備するロジックは複雑になりませんか?
注意:もしactionがユニークIDやランダムな値を必要とするなら、まず最初にactionオブジェクト内で生成します。Reducersは決してランダムな値を計算してはならないです。予測できない結果を引き起こすことはルールに反します。
もし手動でaction creatorを書くならこのようになります。
// hand-written action creator
function postAdded(title, content) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}
でも、Redux ToolitのcreateSlice
はaction creatorsを自動で生成します。書く必要がないのでコードが短くなります。でも、action.payload
の中身をカスタマイズする方法が必要です。
createSlice
はreducerを書くとき”prepare callback”関数を定義できます。”prepare callback”関数は引数を複数指定出来ます。ユニークIDのようなランダムな値やactionオブジェクトに値を決定するのに必要などんな他の同期ロジックが実行されてもよいです。そしてpayload
フィールドの内側と一緒にオブジェクトを返却します。
features/posts/postsSlice.js
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
}
})
コンポーネントはpayloadオブジェクトのことを心配する必要がなく、action creatorが正しい方法で世話をしてくれます。だからpostAdded
をディスパッチするとき引数としてtitle
とcontent
を渡すようにコンポーネントを更新することが出来ます。
features/posts/AddPostForm.js
const onSavePostClicked = () => {
if (title && content) {
dispatch(postAdded(title, content))
setTitle('')
setContent('')
}
}
Users and Posts
いままでずっとstateのsliceを一つだけ持っていました。ロジックはpostsSlice.js
で定義されています、そのデータはstate.posts
に蓄えられています、そしてすべてのコンポーネントがpostsフィーチャーに関係があります。実際のアプリケーションは多くの異なるstateのslice、つまりReactコンポーネントとReduxロジック用のいくつかの異なった”feature folders”を持つでしょう。
アプリケーションにユーザリストからユーザを割り当てる機能を追加しましょう、そしてそのデータを使うpost-related functionalityを更新しましょう。
Adding a Users Slice
“users”と”posts”のコンセプトは違うので、posts用のコードとデータからusers用のコードとデータを分けておきましょう。features/users
フォルダを作成し、usersSlice
ファイルを作成しましょう。動作させるために初期エントリーを追加しましょう。
features/users/usersSlice.js
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
をインポートしてストアセットアップに追加しましょう。
app/store.js
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
}
})
Adding Authors for Posts
あらゆる投稿はユーザ一覧の一人によって書かれたものです。新規投稿するたびに誰がかいた投稿か保持するべきです。実際のアプリでは、ログインしたユーザの保持するstate.currentUser
フィールドがあります。投稿するときはいつでもその情報を使います。
このサンプルではかんたんに扱うために、<AddPostForm>
を更新しましょう。そのコンポーネントでドロップダウンリストを使ってユーザを選択します。投稿の一部にユーザIDを含まます。それぞれの投稿にそのユーザIDでユーザ名を表示します。
最初は、postAdded
action creatorを引数にユーザIDを渡すために更新しましょう。
features/posts/postsSlice.js
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
アクションクリエータに渡します。フォームに少しのバリデーションを追加することが出来ます、「Save Post」ボタンをクリック出来るのは、タイトルとコンテンツをテキストボックスから取得したときだけです。
features/posts/AddPostForm.js
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>
に投稿者の名前を表示する必要があります。1箇所以上に同じ種類の情報を表示したいなら、引数にユーザIDをもつPostAuthor
コンポーネントを作ります。
featrures/posts/PostAuthor.js
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>
}
今までそれぞれのコンポーネントで行ってきたのと同じことに気づくでしょう。コンポーネントはuseSelector
フックでRedux ストアからデータを読み込み、必要なデータを抽出します。多くのコンポーネントは同じタイミングでReduxストアから同じデータにアクセスします。
PostsList.js
とSinglePostPage.js
の両方にPostAuthor
コンポーネントをインポートすることが出来ます。<PostAuthor userId={post.user} />
としてレンダリングします。投稿を追加するたびに、選択したユーザ名が投稿内に表示されます。
More Post Features
投稿の作成と編集ができました。投稿フィードにより有用な付加ロジックを追加しましょう。
Storing Dates for Posts
ソーシャルメディアのフィードはたいてい投稿が作成された時刻でソートされています。そして投稿が作成された日付を表示しています。それをするために、投稿用のdate
フィールドをトラッキングし始めましょう。
post.user
フィールドのように、postAdded
prepare コールバックをアクションがディスパッチされるときにいつもpost.date
が含まれるように更新しましょう。引数に渡すパラメータはありません。アクションがディスパッチされたタイムスタンプを使いたいです。prepareコールバックを操作してみましょう。
ReduxストアにDate
クラスインスタンスを配置することは出来ません。タイムスタンプの文字列としてpost.date
をトラックしましょう。
features/posts/postsSlice.js
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
のようなライブラリーはパースやフォーマットされた日付の有益な関数があります。
features/posts/TimeAgo.js
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>
)
}
Sorting the Posts List
<PostsList>
Reduxストアで投稿された順番にすべての投稿を表示しています。今の例は最初に一番古い投稿があり、新しい投稿をするたびに、投稿の配列の最後に追加されます。最新の投稿はいつもページの下にあります。
一般的にはソーシャルメディアフィードは新しい投稿を最初に表示します。スクロールするとより古い投稿を見ることが出来ます。データはストアで古いものが最初のままだけど、<PostsList>
コンポーネントで新しい投稿順にデータを並び替えることが出来ます。state.posts
配列はすでにソートされています。そのリストを逆順にしましょう。
array.sort()
はすでにある配列を変更するので、state.posts
をコピーする必要があります。post.date
フィールドは日付のタイムスタンプ文字列です。直接それらを比較して正しい順番に投稿を並べ替えましょう。
features/posts/PostList.js
// 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
を再び使って現在の日時からどれだけ経過したを抽出しましょう。
features/posts/postsSlice.js
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()
}
]
Post Reaction Buttons
ここではもう一つ新しい機能を追加しましょう。今の投稿は退屈です。もっとエキサイティングにする必要があります。そうするためには投稿に友達から絵文字のリアクショを追加するようにするとどうでしょうか。
<PostsList
と<SinglePostPage>
にそれぞれの投稿の下に絵文字のリアクションボタンを1行追加しましょう。ユーザがリアクションボタンをクリックするたびにReduxストアの投稿のカウンターフィールドを更新します。Reduxストアにリアクションのカウンターデータがあるのでアプリの異なる部分でスイッチすることでそのデータを使ったコンポーネントで一貫して同じ値を表示します。
記事の投稿者とタイムスタンプのように、投稿を表示する新湯ところで使いたい、だからpost
をpropに持つ<ReactionButtons>
コンポーネントを作ります。ボタンを表示するところから始めましょう、それぞれのボタンはリアクションカウントを持っています。
features/posts/ReactionButtons.js
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.reactiosn
フィールドをデータに持っていません、だからpostAdded
prepareコールバックに投稿が内部にデータを持つようにinitialState
の投稿オブジェクトを更新しましょう。
リアクションボタンをユーザがクリックしたときに投稿にリアクションカウントを更新する新しいレデューサを定義します。
投稿の編集のように、ユーザがクリックしたリアクションボタンの投稿のIDは知っている必要があります。{id, reaction}
のようなオブジェクトであるaction.payload
を用意します。そのレデューサは正しい投稿オブジェクトを見つけます、そして正しくリアクションフィールドを更新します。
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
はレデューサで可変ロジックで記述させてくれます。createSlice
とImmer libraryを使わないなら、existingPost.reactions[reaction]++
は存在するpost.reactiosn
オブジェクトを変更してしまうでしょう。それはアプリケーションのあらゆる箇所でおそらくバグを引き起こします。レデューサのルールに従わないからです。createSlice
を使えばより複雑な更新ロジックを簡単な方法で書くことが出来ます。Immerは安全なイミュータブルな更新にコードを変更してくれます。
アクションオブジェクトは何が起こったかを詳細にするに必要な最小量の情報を含みます。どのリアクションがクリックされたかと、更新したい投稿を知っています。新しいレアクションカウンターの値を計算出来ます。可能な限り小さいアクションオブジェクト保持することは常に良い手段です。レデューサで計算値を更新します。このことはレデューサが新しい状態を計算するの必要なロジックを含んでいることを意味します。
ユーザがボタンをクリックしたときreactionAdded
actionをディスパッチするための<ReactionButtons>
コンポーネントの更新しましょう。
reatures/posts/ReactionButtons.jsx
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>
でリアクションボタンをクリックしたら<SinblePostPage>
でそれを投稿で見る事ができます。