Using Redux Data~最もシンプルなReduxアプリケーションで構造を理解しよう

Using Redux Data

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をディスパッチするとき引数としてtitlecontentを渡すようにコンポーネントを更新することが出来ます。

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.jsSinglePostPage.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}>
      &nbsp; <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.jsinitialStatedateフィールドを追加しましょう。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>でそれを投稿で見る事ができます。

目次に戻る

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

先頭に戻る