introduce

In Part 3: Basic Redux Data Flow, we learned how to start with an empty Redux + React project setup, add a new state slice, and create a React component that reads data from the Redux Store and dispatchactions to update that data. We also looked at how data flows through the application, with the component dispatching actions, the Reducer processing the action and returning the new state, and the component reading the new state and rerendering the UI.

Now that you know the core steps for writing Redux logic, we’ll use the same steps to add some new features to the social media stream that will make it even more useful: view individual posts, edit existing posts, display details about the author of the post, post timestamps, and comment buttons.

information

As a reminder, these code examples focus on key concepts and changes in each section. For complete changes in the application, see the Tutorial-Steps branch in the CodeSandbox project and project repository.

Viewing a single Post

Since we have the ability to add new posts to the Redux Store, we can add more features that use posts data in different ways.

Currently, our POST entries show up on the main streaming page, but if the text is too long, we just show an excerpt of the content and then look at the details on our own page corresponding to the post.

Create a separate Post page

First, we need to add a new SinglePostPage component to our Posts feature folder. We’ll use the React Router to display this component when the page URL looks like /posts/123, where part 123 should be the ID of the post we want to display.

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>)}Copy the code

The React Router passes in a match object as a prop that contains the URL information we are looking for. When we set up the route to render the component, we will tell it to parse the second part of the URL into a variable named postId, and that value can be read from mate.params.

Once you have that postId value, you can use it in the selector function to find the correct POST object from the Redux store. We know that state.posts should be an Array of all post objects, so we can use array.find () to iterate through the Array and return the post entry with the desired ID.

It is important to note that the component is rerendered whenever the value returned by useSelector changes to a new reference. Components should always try to select the minimum amount of data needed from the Store, which will help ensure that data is rendered only when it is actually needed.

There might be no matching post entry in the Store — either the user is trying to type the URL directly, or we’re not loading the right data. If this happens, the find() function returns undefined instead of the actual POST object. Our component needs to be checked and checked by displaying “Post not found! To deal with it.

Assuming that the store does have the correct POST object, useSelector will return that object, which we can use to render the title and content of the post on the page.

You may notice that this looks very similar to the logic in the body of the component, where we iterate through the entire Posts array to display the list of posts with the main feed. We can try to extract a Post component that can be used in both places, but there are already some differences in the way the Post summary and the entire Post are displayed. Even if there is some duplication, it is usually best to write separately for a while and then decide if the different parts of the code are similar enough that we can actually extract reusable components later.

Add a route to the standalone Post page

Now that we have a

component, we can define a route to display it and add a link to each post in the home page feed.

We’ll import SinglePostPage in app.js and add the following routes:

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>)}Copy the code

Then, in , we’ll update the list rendering logic to wrap a route to that particular post :

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>)}Copy the code

Also, since we can now click to go to another page, it helps to also have a link to the main Posts page in the
component:

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>)}Copy the code

Edit the post

It’s annoying as a user to finish a post, save it, and realize you made a mistake somewhere in the post. It would be useful to have the ability to edit a POST after it is created.

Let’s add a new

component that gets the ID of an existing post, reads the post from the store, lets the user edit the title and post content, and then saves the changes to update the post in the store.

Update a POST entry

First, we need to update our postsSlice to create a new Reducer function and an action so that the Store knows how to update posts.

Inside the createSlice() call, we should add a new function to the Reducers object. Remember that the reducer name should be a good description of what is happening, because whenever we dispatch this action, we will see the Reducer name displayed in Redux DevTools as part of the action Type string. Our first reducer is called postAdded, so we call this added postUpdated.

To update a POST object, we need to know:

  • The ID of the post being updated so that we can find the appropriate POST object in state
  • New user inputtitleandcontentfield

The Redux Action object must have a Type field, which is typically a descriptive string, and may also contain other fields that contain more information about the event that occurred. By convention, we usually put additional information in a field called action.payload, but it’s up to us to decide what the payload field contains — it can be a string, a number, an object, an array, or something else. In this case, since we need three pieces of information, let’s plan for the payload field as an object with three fields. This means that the action object will look like {type: ‘posts/postUpdated’, payload: {ID, title, content}}.

By default, the Action Creator generated by createSlice expects you to pass in a parameter, and that value will be placed in the Action object as action.payload. Therefore, we can pass the object containing these fields as a parameter to postUpdated Action Creator.

We also know that the Reducer is responsible for determining how state should be updated during dispatch actions. Given this, we should have the Reducer find the correct POST object based on the ID and specifically update the title and content fields in that post.

Finally, we need to export the Action Creator function generated by createSlice for us so that the UI can schedule the new postUpdated Action when the user saves the post.

With all these requirements in mind, after completion, here is our postsSlice definition:

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
Copy the code

Create an edit POST form

Our new

component looks similar to

, but the logic needs to be different. We need to retrieve the correct POST object from the Store, and then use that object to initialize the state field in the component for the user to make changes. When the user is done, we save the changed title and content values back to store. We also switch the History API using the React Router to a single POST page and display the post.

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>)}Copy the code

As with SinglePostPage, we need to import it into app.js and add a route that displays this component. We should also add a new link to our SinglePostPage that will be routed to EditPostPage, for example:

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>
Copy the code

Payload for the action

We just see that the Action Creator on createSlice usually has one parameter, action.payload. This is the most common usage pattern to simplify, but sometimes we need to do more work to prepare the content of the Action object. For the postAdded action, we need to generate a unique ID for the new post, and we also need to ensure that the payload is an object that looks like {ID, title, content}.

Now we are generating the ID and creating the Payloa object in the React component and passing the Payloa object to postAdded. But what if we need to dispatch the same action from different components, or the logic of preparing Payloa is complicated? Every time we want to dispatch an action, we have to repeat that logic, and we force the component to know exactly what the Payloa of that action should look like.

warning

If an action needs to contain a unique ID or other random value, always generate that ID first and put it into the Action object. Reducer should never evaluate random values, as this will make the result unpredictable.

If we were writing postAdded’s Action Creator manually, we could put the setup logic itself into it:

// hand-written action creator
function postAdded(title, content) {
  const id = nanoid()
  return {
    type: 'posts/postAdded'.payload: { id, title, content }
  }
}
Copy the code

However, the createSlice of the Redux Toolkit is generating these Action Creators for us. This makes the code shorter because we don’t have to write our own code, but we still need a way to customize the content of action.payload.

Fortunately, when we wrote a Reducer, createSlice allowed us to define a “Prepare Callback” function. The “Prepare Callback” function can take multiple arguments, generate random values such as unique ids, and run any other synchronization logic needed to determine which values to put into the Action object. It should then return an object with an internal payload field. (The return object may also contain a META field, which can be used to add additional descriptive values to the action, and an error field, which should be a Boolean value indicating whether the action represents some kind of error.)

In the Reducers field of createSlice, we can define one of the fields as an object that looks like {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}})Copy the code

Now, our component doesn’t have to worry about what the payload object looks like — Action Creator takes care of putting it together in the right way. Therefore, we can update the component to pass title and content as parameters when dispatch postAdded:

const onSavePostClicked = () = > {
  if (title && content) {
    dispatch(postAdded(title, content))
    setTitle(' ')
    setContent(' ')}}Copy the code

The Users and posts

So far, we only have one state. This logic is defined in postsslice.js, the data is stored in state.posts, and all of our components are related to posts functionality. A real application might have many different state slices, as well as several different “feature folders” for the Redux logic and React components.

Your “social media” app doesn’t mean anything if no one else is involved. Let’s add a feature to keep track of the Users list in our application and update posts-related features to take advantage of that data.

Add a Users slice

Since the concept of “users” is different from the concept of “posts,” we want to separate the code and data for Users from the code and data for posts. We will add a new Features/Users folder and place a usersSlice file in it. Like Posts Slice, now we’ll add some initial entries so we can use the data.

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
Copy the code

For now, we don’t need to actually update the data, so we leave the Reducers field as an empty object. (We’ll discuss this again in a later section.)

As before, we imported the usersReducer into our store file and added it to the Store Settings:

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
  }
})
Copy the code

Add the author of the post

Each post in our application is written by one of our users, and each time we add a new post, we should keep track of which user wrote the post. In a real application, we would have some kind of state.currentUser field to keep track of the currently logged in user and use that information when they add a POST.

To make this example simpler, we will update the

component so that we can select a user from the drop-down list and include the USER’s ID in the POST. Once we have the USER’s ID in our POST object, we can use that ID to look up the user name and display it in each individual post in the UI.

First, we need to update postAdded Action Creator to accept the USER’s ID as a parameter and include it in the action. (We will also update the existing POST entry in initialState to have a post.user field with one of the ids of the example 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}})Copy the code

Now, in our

, we can use useSelector to read the Users list from the store and display it as a drop-down list. We then get the ID of the selected user and pass it to postAdded Action Creator. Along the way, we can add some validation logic to the form so that the user can click the “Save Post” button only if the title and content input contain some actual text:

// 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>)}Copy the code

Now, we need a way to display the post author’s name in the Posts list item and

. Since we want to display the same information in multiple places, we can make a PostAuthor component that looks for the correct user object with the USER ID as props and formats the user name:

// features/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>
}
Copy the code

Notice that we follow the same pattern in each component. Any component that needs to read data from the Redux Store can use the useSelector hook and extract the specific data it needs. Similarly, many components can access the same data in the Redux Store at the same time.

Now we can import the PostAuthor component into postslist.js and SinglePostPage.js and render it as , and every time we add a POST entry, The name of the selected user should appear in the POST.

More Post features

At this point, we can create and edit posts. Let’s add some additional logic to make our Posts feed more useful.

The date to store posts

Social media feeds are usually sorted by the time the post was created and show us when the post was created in relative terms, such as “5 hours ago.” To do this, we need to start tracking the date field of the POST entry.

As with the Post. user field, we will update the Prepare Callback for postAdded to ensure that post.date is always included in the Dispatch action. However, this is not another parameter to pass in. We want to always use the exact timestamp of the Dispatch action, so we let the Prepare Callback handle it itself.

warning

Redux Action and state should contain only ordinary JS values, such as objects, arrays, and value types. Do not put class instances, functions, or other non-serializable values into Redux! .

Since we can’t put an instance of the Date class into the Redux Store, we trace the post. Date value as a timestamp string:

// 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,
          },
        }
      },
    },
Copy the code

As with the POST author, we need to display the relative timestamp description in the and

components. We will add a

component to handle the relative description of the formatted timestamp string. Libraries such as date-fns have some useful utility features for parsing and formatting dates, which we can use here:

// 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>)}Copy the code

Sort the posts list

Currently, our shows all posts in the order they were saved in the Redux store. Our example shows the earliest post first, and it is added to the end of the Posts array each time a new post is added. This means that the most recent post is always at the bottom of the page.

Typically, social media feeds show the most recent posts first, then scroll down to see older posts. Even if the data remains the oldest priority in the store, we can reorder the data in the component to make the most recent messages take precedence. Theoretically, since we know that the state.posts array is sorted, we can reverse the list. However, it’s best to sort yourself to be sure.

Since array.sort() changes an existing array, we need to make a copy of state.posts and sort it. We know that our post.date field is kept as a date-timestamp string, and we can compare them directly to sort posts in the correct order:

// features/posts/PostsList.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>)})Copy the code

We also need to add the date field to initialState in postsslice.js. We’ll use date-fns here again to subtract minutes from the current date/time so that they differ from each other.

// 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()
  }
]
Copy the code

Post comment button

We’ve added another new feature to this section. Now, our posts are a little boring. We need to get them more excited, and what better way to do that than to ask our friends to add comment emojis to our posts?

We will add a line of emoji comment buttons at the bottom of each post in and

. Each time a user clicks one of the rating buttons, we need to update the matching counter field in the Redux Store for that post. Because the evaluation counter data is located in the Redux Store, switching between different parts of the application should always display the same value in any component that uses the data.

As with Post authors and timestamps, we want to use Post wherever it is displayed, so we will create a < buttons > component using Post as a prop. We start by showing the internal buttons and the current rating count for each button:

// 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>
}
Copy the code

We don’t have the Post. Reactions field in our data yet, so we need to update the initialState Post object and the postAdded Prepare Callback function to make sure that each post has this data, such as Comment: Reactions: {thumbsUp: 0, hooray: 0}.

Now we can define a new Reducer that will handle the evaluation count for updated posts when the user clicks the Evaluation button.

As with editing a Post, we need to know the ID of the post and which comment button the user clicked. So we’re going to have an action. Payload is an object that looks like {ID, reaction}. The Reducer can then find the correct POST objects and update the correct evaluation fields.

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
Copy the code

As we have already seen, createSlice allows us to write “change” logic in the Reducer. If we don’t use createSlice and the Immer library, the ExistingPost. Reactions ++ line does change the existingPost. Reactions object, but this can cause errors elsewhere in the application, Because we did not follow the Reducer rules. However, because we are using createSlice, we can write this complex update logic in a simpler way and have Immer perform it by converting the code to secure immutable.

Note that our Action object contains only the minimum information needed to describe what is happening. We know which post we need to update and which rating name we clicked. We can calculate the new evaluation counter value and put it into the action, but it is always best to keep the action object as small as possible and do the state update calculation in the Reducer. This also means that the Reducer can contain as much logic as is needed to calculate the new state.

information

With Immer, you can either “change” an existing state object or return a new state value yourself, but not both. For more details, see the Immer documentation guide on traps and returning New Data.

Our final step is to update the < buttons > component to dispatch responseAdded action when a user clicks a button:

// features/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>
}
Copy the code

Now, every time we click a rating button, the counter increases. If we browse through different parts of the application, we can see the updated data even if we click the comment button in and then in

.