Roll your Own Redux With useContext & useReducer

“Most projects aren’t complex enough to require Redux.”

I hear this refrain from the React community often. I’m not here to debate how valid it is. What I do know is that there are situations where you do want to share state between multiple components, and you may not want to bring Redux into your architecture.

In this tutorial, you’ll learn how to create your own mini-state management system. I call them reducklings.

Photo by Joshua Fuller on Unsplash

Our Use Case: Flash Messaging

By default Ruby on Rails includes flash messaging. Within your controller, you can easily dispatch a message to display on the screen. In our application, we want to something similar:

  • Display one or more messages at the top of the screen.
  • Be able to dismiss a single message.
  • Have the ability to clear all of the messages.
  • Any component should be able to dispatch a message.

1: Build Our Reducer

So for our messaging queue, it looks like we have a state that we want to perform several actions on. It’s a perfect use case for creating a reducer. Here’s what that looks like:

const messageReducer = (state, action) => {
  switch (action.type) {
    case 'ADD':
      return [

        ...state,
        action.payload,
      ]
    case 'CLEAR':
      return []
    case 'DISMISS':
      return state.filter((message, index) => index !== action.payload)
    default:
      return state
  }
}

2: Create a Context

In the next step, we’ll create a state array and dispatch function using useReducer. But first, we need a place to store them. This is where the magic happens. We are going to store both the state and dispatch in a context so we can access them from anywhere. Let’s being by creating our context:

const MessageContext = React.createContext({
  state: [],
  dispatch: null,
})

3: Providing the Reducer

At the top level of our application, or the highest level where you want to have access to the duckling, you’ll want to pass the results of creating a reducer into the context.

import React, { useReducer } from 'react'
import { messageReducer, MessageContext } from './message_duckling

const App = () => {
  const [state, dispatch] = useReducer(messageReducer, [])
  return ( 
    <MessageContext.Provider value={{state, dispatch}}>
      {/* Your App Here */}
    </MessageContext>
  )
}

Now we have access to our state and dispatch functions anywhere in the application!

4: Accessing The Messages With UseContext

Let’s look at our first use case, reading the messages within a component.

import React, { useContext } from 'react'
import { MessageContext } from './message_context'

const MessageContainer = () => {
  const { state, dispatch } = useContext(MessageContext)

  return (
    <div className="messages-container">
      {state.map((message, index) => (
        <div className={`message ${message.type}`}>
          <span>{message.text}</span>
        </div>
      ))}
    </div>
  )
}

export default MessageContainer

5: Dispatch Actions

In a similar fashion to redux, we can use the dispatch function to update the messages. Here’s a form component that will create a message:

import React, { useState, useContext } from 'react'
import { MessageContext } from './message_context'

const MessageForm = () => {
  const [text, setText] = useState('')
  const { dispatch } = useContext(MessageContext)
  const createMessage = (e) => {
    e.preventDefault()
    const newMessage = { type: 'warning', text }
    dispatch({
      type: 'ADD',
      payload: newMessage
    })
  }

  return (
    <form onSubmit={createMessage}>
      <input type={text} onChange={e => setText(e.target.value)} />
      <input type="submit" value="post message" />
    </form>
  )
}

export default MessageForm

Bonus Points: HOCs and Custom Hooks

To make your code a bit more clear, you can wrap up your useReducer as its own custom hook. To make the code more extensible, you could also add an option to allow users to define their own initial state:

const useMessageReducer = (initial_state = []) {
  return useReducer(messageReducer, initialState)
}

Something else that could be useful would be to create a higher-order component that passes along your duckling to any function. This way you can have functionality similar to Redux’s connect function:

const withMessageStore = (WrappedComponent) => (props) => {
  const { state, dispatch } = useContext(MessageContext)
  return (<WrappedComponent
    {...props}
    messageStore={state}
    messageDispatch={dispatch}
  />)
}

Review: Features of A Duckling

What does a duckling include?

  • A context that we can reference anywhere in our application.
  • That context comes with a global state and a dispatch function that lets us edit that state via a reducer.
  • Optionally, it could include a custom hook and higher-order component to make it easier to implement.

Now, let’s look at some of the features that are included in Redux that we don’t have here.

What A Duckling Isn’t

  • A duckling does not provide actions, types, or action creators.
  • A duckling does not bother with state and dispatch mapping. Every component gets the whole state and the whole dispatch. No mapStateToProps or mapDispatchToProps here.
  • As a consequence of that, we don’t really have selectors either. Though you could possibly build them.
  • It has no concept of middleware.

If you are in a situation where believe that the problem you are trying to solve needs more of this functionality, then you know you have a stronger use case for using Redux! Don’t take this advice and re-invent the wheel when you don’t need to. Instead use it when you need smaller wheels for shorter, simpler trips.