loading...
Cover image for How to manage a complex UI state with useReducer hook instead of redux

How to manage a complex UI state with useReducer hook instead of redux

kpunith8 profile image Punith K ・4 min read

If you are the one using the most popular frontend library React to build your UI, you might have definitely heard about props and state which triggers the UI update as and when there is a change to either of them; the state could be a local state(synchronous) or network state(asynchronous).

Managing the state in the React is always been a talk of the down with so many libraries in hand such as, redux, mobx, recoil, and the list goes on, let me explain to you how we can leverage this without adding any additional dependency to the app and reduce the bundle size of the app.

If you are using React for quite a while (at least from React 16.3) you might have heard about one of the most popular library Redux to manage the complex UI state, because of its predictable state and support for async state management with the help of redux-thunk and redux-saga libraries.

There are plenty of libraries that you can use it as middleware in redux and extend the redux capabilities. If you are setting up the redux from scratch you need to have boilerplate code set up before you start working on it. Recent versions of redux offer hooks-based API to reduce some of the boilerplate but still, you need to know about actions, reducers, middlewares, and so on.

If you are using the latest React or React 16.8 or above, you might already be using one of the most popular features introduced in react, hooks. Hooks help you write the components without writing classes and manage the state of the react app with ease.

In this post I'll explain the usage of useReducer hook with the help of other hooks, such as useEffect, useMemo, useRef, and useState to manage the complex UI state without using the redux. This post assumes that you all know the basics of hooks and how to use them. If you have not used it before I recommend you all to read the official documentation for getting started.

Let's assume that we are building a simple book library CRUD app, where you can add, delete, and manage your library based on your interests. I'm using one of the React UI patterns used widely with redux, container, and presentational components pattern to demonstrate this example, this can fit any pattern you are already using.

books-container.js

import React, {useReducer, useMemo, useEffect, useRef} from 'react'
import _ from 'lodash'
import BooksLayout from './books-layout'

// Extract this to utils file, can be reused in many places
// Same as that of redux's bindActionCreators method
const bindActionCreators = (reducerMap, dispatch) =>
  _.reduce(
    reducerMap,
    (result, val, type) => ({
      ...result,
      [type]: payload => dispatch({type, payload}),
    }),
    {}
  )

// Initial state of the app
const initialState = {
  books: {}, 
  // To keep track of progress of a API call and to show the 
  // progress in the UI
  bookReadState: null
  bookDeleteState: null
  bookUpdateState: null
}

const reducerMap = {
  setBooks: (state, books) => ({
    ...state,
    books,
  }),
  updateBook: (state, book) => ({
    ...state,
    books: // merge state.books with updated book details
  },
  deleteBook: (state, book) => ({
    ...state,
    books: // update the state.books with deleted book
  }),
  setBookReadState: (state, bookReadState) => ({
    ...state, bookReadState
  }),
  setBookUpdateState: (state, bookUpdateState) => ({
    ...state, bookUpdateState
  }),
  setBookDeleteState: (state, bookDeleteState) => ({
    ...state, bookDeleteState
  }),
}

const useService = ({id, actions}) => {
  // abortController can be used to abort the one or more request
  // when required, can also be used to abort when multiple requests are made
  // within a short period, so that you don't make multiple requests
  const abortController = useRef(new global.AbortController())

  actions = useMemo(
    () => ({
      ...actions,
      readBooks: async () => {
        try {
          const data = await readBooks({
            fetchCallback: actions.setBookReadState})
          actions.setBooks(data)
        } catch(error) {
          // error handling
        }
      },
      updateBook: async book => {
        try { 
          const data = await updateBook({book, 
            fetchCallback: actions.setBookUpdateState})
          actions.updateBook(data)
        } catch(error) {
          // error handling
        }
      },
      deleteBook: async id => {
        try {
          const data = await deleteBook({id, 
            fetchCallback: actions.setDeleteReadState})
          actions.deleteBook(data)
        } catch {
          // error handling
        }
      },
    }),
    [actions]
  )

  useEffect(() => {
    const controller = abortController.current
    // Invoke the actions required for the initial app to load in the useEffect.
    // Here I'm reading the books on first render
    actions.readBooks()

    return () => {
      controller.current.abort()
    }
  }, [actions])

  return {actions}
}

const reducer = (state, {type, payload}) => reducerMap[type](state, payload)

const BooksContainer = props => {
  const [state, dispatch] = useReducer(reducer, initialState)
  const actions = useMemo(() => bindActionCreators(reducerMap, dispatch), [])
  const service = useService({...props, state, actions})

  return (
    <BooksLayout
      {...state}
      {...service}
      {...props}
    />
  )
}

export default BooksContainer

books-layout.js

import React from 'react'

const BooksLayout = ({books, actions, bookReadState, ...props}) => {
  return (
    <>
    {bookReadState === 'loading' ? <div>Loading...</div> : 
      {books.map(book => (
          // UI Logic to display an each book
          // button to click to delete 
          // call actions.deleteBook(id)
          )
        )
      }
    }
    </>
  )
}

export default BooksLayout

As you can see in the above example you can control the state of your app in the container and don't have to worry about connecting the state to each component separately as you need to do in redux.

In the above example, I kept all the code in a single file for the demonstration purpose and parts of the code were not complete, replace the code with your abstractions for network calls, business logic, and UI logic based on your needs. You can improve this code by separating the logic based on your needs for more reusability across the app as the DRY(Don't Repeat Yourself) principle suggests.

Redux shines and scales well for complex apps with a global store. In this article, I'm trying to explain, how you can leverage the useReducer in place of redux to achieve the global state management with less code, and no need to worry about adding new packages to the app and we can reduce the bundle size of the app significantly.

Please leave a comment and follow me for more articles.

Posted on by:

kpunith8 profile

Punith K

@kpunith8

Loves coding with JS, Java, exploring Python, Dart, and Flutter. Codes with NodeJS, React, Redux, Cypress, HTML, CSS. Knows about REST API's, Testing, Docker, AWS and lot more

Discussion

pic
Editor guide