tonygo_
NUIT

🍕Refactoring reducers in React application (part 2)

How to refactor old reducers with createSlice & createAsyncThunk (redux toolkit)

Redux - React - Refactoring - 2020-05-15

This article is the second part of the "Refactoring reducers in React application" post.

Implement

If you want you can follow this post by reading the source code here: https://codesandbox.io/s/zen-bhabha-1jpws?file=/src/middleware/notify.js

A - Store

Imagine you have to implement a back-office blog application. You’ll have a UI, with a list of posts on the left part of the screen, and post details on the right (we didn't focus on style and UX here).

So you gonna store the data in two reducers:

  • list reducer will contain all blog posts, where posts are objects (not full)
  • properties reducer will contain a full blog post objects with all details on it

We split it like that because of the behavioral difference between the list component and the properties component.

Another way to shape the state is to use normalization.

Here a model of how we store data in those reducers:

// postEntities reducer state
{
  data: {
    [key: string]:  { // id
      name: string,
      id: string,
      status: 'draft' | 'published',
    }
  },
  status: 'rejected' | 'fulfilled' | 'pending' | 'NONE',
  error: object
}

// currentPost reducer state
{
  data: {
    id: string,
    title: string,
    status: 'draft' | 'published',
    content: string,
  },
  status: 'rejected' | 'fulfilled' | 'pending' | 'NONE',
  error: object
}

You probably note that we got some similarities in those two reducers: the model structure is the same. Why keep a common structure for all those reducers?

  • You may develop some generic reducers helpers which could be reused in every reducer like (setPending, setFulfilled or setRejected)
  • It will make selector industrialization easier (with generics selectors too)
  • You naturally establish some guidelines for all future features

Note: When you start something like this don’t forget to add documentation (or proposal) in the repository!

Now it’s clear I have my model in mind, let’s write reducers and actions :

Reducers

We want to provide an action which will allow a user to update a property of the blog (title or content) by double-clicking on the property.

Add a properties reducers :

import { createSlice } from '@redux/toolkit'
import { get, set } from 'lodash'

const currentPostSlice = createSlice({
  name: 'currentPost',
  initialState: {
      data: {},
      status: 'NONE',
      error: {}
  }
  reducers: {
    setProperty: (state, action) => {
      const { path, value } = get(action, "payload");
      set(state, ["data", path], value);
    }
  }
})

export default currentPostSlice.reducer
export const { actions } = currentPostSlice

What are we doing here :

We have used createSlice helper from redux-toolkit. It’s a new way to create reducers, it :

  • avoids to create action creator functions, and action types too
  • allows modifying state object directly without caring about immutability
  • handle actions from other slices/reducers separately (we'll use it below)

Now, implement our entitiesPost reducers :

import { createSlice } from '@redux/toolkit'
import { actions } from './currentPost'
import { get, set } from 'lodash'

const entitiesPostSlice = createSlice({
  name: 'entitiesPost',
  initialState: {
    data: {},
    status: 'NONE',
    error: {}
  }
  extraReducers: {
    [propertiesAction.setProperty]: (state, action) => {
      const { id, path, value } = get(action, "payload");
      set(state, ["data", id, path], value);
    }
  }
})

export default entitiesPostSlice.reducer

Owh! What is this extraReducers property?

This createSlice object property allows you to handle action from other reducers. It let you read the fabulous documentation from redux-toolkit if you want to dig a bit more.

What we got here: we got two reducers that will trig the same action.

Async Actions

Oups !!! We don’t have to create action functions Ahah! createSlice do the job for us.

But what about asynchronous actions? Don’t panic, redux-toolkit provides us another new tool called createAsyncThunk. This function is a thunk wrapper that takes a name (action type) and a callback as parameters. What does this wrapper do?

“It generates promise lifecycle action types based on the provided action type, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise” - Redux toolkit documentation

How to implement it :

In propertiesSlice:

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"
import { get, set } from "lodash"

import { getPost } from "../api"

const fetchPost = createAsyncThunk("properties/fetchPost", async id =>
  getPost(id)
)

const propertiesSlice = createSlice({
  name: "properties",
  initialState: {
    data: {},
    status: "NONE",
    error: {},
  },
  reducers: {
    setProperty: (state, action) => {
      const { path, value } = get(action, "payload")
      set(state, ["data", path], value)
    },
  },
  extraReducers: {
    [fetchPost.pending]: state => {
      state.status = "pending"
    },
    [fetchPost.fulfilled]: (state, action) => {
      state.status = "fulfilled"
      state.data = action.payload
    },
    [fetchPost.rejected]: (state, action) => {
      state.status = "rejected"
      state.error = action.error
    },
  },
})

export default propertiesSlice.reducer
export const { actions } = propertiesSlice
export const asyncActions = { fetchPost }

In listSlice:

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"
import { get, set } from "lodash"

import { getPosts } from "../api"
import { actions as propertiesAction } from "../Properties/propertiesSlice"

const fetchPosts = createAsyncThunk("list/fetchPosts", async () => getPosts())

const listSlice = createSlice({
  name: "list",
  initialState: {
    data: {},
    status: "NONE",
    error: {},
  },
  extraReducers: {
    [fetchPosts.pending]: state => {
      state.status = "pending"
    },
    [fetchPosts.fulfilled]: (state, action) => {
      state.status = "fulfilled"
      state.data = action.payload.reduce((acc, curr) => {
        acc[curr.id] = curr
        return acc
      }, {})
    },
    [fetchPosts.rejected]: (state, action) => {
      state.status = "rejected"
      state.error = action.error
    },
    [propertiesAction.setProperty]: (state, action) => {
      const { id, path, value } = get(action, "payload")
      set(state, ["data", id, path], value)
    },
  },
})

export default listSlice.reducer
export const { actions } = listSlice
export const asyncActions = { fetchPosts }

If you're usually using combine reducers, you can always do it:

import { createStore, combineReducers, applyMiddleware } from "redux"
import thunk from "redux-thunk"

import properties from "./features/Blog/Properties/propertiesSlice"
import list from "./features/Blog/List/listSlice"

const reducers = combineReducers({
  properties,
  list,
})

export default createStore(reducers, applyMiddleware(thunk))

You have to install redux-thunk middleware and plug it to the store to use createAsyncThunk functions.

Smart Idea: create generic functions to handle pending, fulfilled and rejected actions from each slice

If we make a quick recap, we got :

  • reducers (slice)
  • actions and async actions (thunk)

What about our notifications? In our context we will use a library like react-s-alert, so we just have to call a function. Let’s write a redux middleware …

Middleware

A middleware redux is a function called between the moment where the action is called and the moment it reaches the reducers. (If don’t know anything about redux middleware, take a look on redux-documentation)

Reminder: redux middleware has access to:

  • storeApi (dispatch, getState)
  • action payload object
  • next function

Here is signature of a middleware :

const middleware = store => next => action => {
  // do your stuff here !
  next(action)
}

The first (naive) way to do this is to use a switch case and import your notification service in the middleware, like :

import notification from './service/notify'
import { asyncActions } from './store/post/current'

const notificationMiddleware = store => next => action => {

  switch(action.type) {
    case actions.fetchPosts.fulfilled.type:
        notification('Post fetched', 'success')
    ...
  }

  next(action)
}

This implementation had some bad points :

  • You have to update middleware when you wan to add some cases, it increases the risk of adding bugs
  • If you want to plug a new notification manager, you should update all the calls in the switch case statement

Maybe using dependency injections, in this case, will be relevant. We could create a function that takes a notify function and an object with all cases in and return a middleware, like:

const createNotificationMiddleware = (
  notify,
  cases
) => store => next => action => {
  const currentCase = cases[action.type]

  if (currentCase) {
    notify(currentCase.message, currentCase.logLevel)
  }

  next(action)
}

We got something cleaner here! Our middleware is agnostic from cases and from the notify function.

Let's instantiate our middleware:

import { actions } from "../features/Blog/Properties/propertiesSlice"
import { asyncActions } from "../features/Blog/List/listSlice"
import notifyService from "../services/notify"

const cases = {
  [actions.setProperty.type]: {
    message: "Property updated",
    logLevel: "info",
  },
  [asyncActions.fetchPosts.fulfilled.type]: {
    message: "Post fetched",
    logLevel: "success",
  },
}

export default createNotificationMiddleware(notifyService, cases)

Our store is now ready! Let’s plug the components

B - Components

Waouh! We saw a bunch of things in the previous part, be reassured the next part will be lighter than the previous.

Like a I said above the store is the application heart, when he is fine, all the app is fine :)

List of posts

We use useSelector to get state data and useDispatch to dispatch action:

import React, { useEffect } from "react"
import PropTypes from "prop-types"
import { useSelector, useDispatch } from "react-redux"

import Status from "../../../common/Status"
import { asyncActions } from "./listSlice"

List.propTypes = {
  setPost: PropTypes.func.isRequired,
}

export default function List({ setPost }) {
  // useSelector takes a selector function
  const posts = useSelector(state => Object.values(state.list.data))
  const status = useSelector(state => state.list.status)

  // useDispatch returns the dispatch function
  const dispatch = useDispatch()
  useEffect(() => {
    dispatch(asyncActions.fetchPosts())
  }, [dispatch])

  if (status === "pending") {
    return <div style={style}>Loading ...</div>
  }

  return (
    <div>
      <p>Posts</p>
      {posts.map(({ title, status, id }) => (
        <div key={id} onClick={() => setPost(id)}>
          <p>{title}</p>
          <Status status={status} />
        </div>
      ))}
    </div>
  )
}

Properties of a post

import React, { useEffect } from "react"
import { useDispatch, useSelector } from "react-redux"

import Status from "../../../common/Status"
import { asyncActions, actions } from "./propertiesSlice"

export default function Properties({ selectedPost }) {
  const post = useSelector(state => state.properties.data)
  const status = useSelector(state => state.properties.status)
  const dispatch = useDispatch()

  const handleUpdate = field => () => {
    const value = prompt(`New ${field} value`)
    dispatch(actions.setProperty({ path: field, value, id: post.id }))
  }

  useEffect(() => {
    if (selectedPost) {
      dispatch(asyncActions.fetchPost(selectedPost))
    }
  }, [selectedPost, dispatch])

  if (status === "NONE") {
    return <div style={{ paddingLeft: "20px" }}>Select a post</div>
  }

  if (status === "pending") {
    return <div style={{ paddingLeft: "20px" }}>Loading ...</div>
  }

  return (
    <div style={{ paddingLeft: "20px" }}>
      <div>
        <h2 onDoubleClick={handleUpdate("title")}>{post.title}</h2>
        <Status status={post.status} />
      </div>
      <p onDoubleClick={handleUpdate("title")}>{post.content}</p>
    </div>
  )
}

Conclusion

Components and reducers are quite simple now.

I let you a code sandbox here to play with code.

Feel free to ask me questions about implementation.

Back to articles
tony-gorez

Hi đź‘‹ ! My name is Tony, Im a software developer. As software engineering is about learning again and again, I decided to put all the stuff that I'll learn here !