Writing Our Own In-house Redux (Kind Of)

At Yext, we use React for all new frontend applications. When it comes to state management libraries to go with React, it doesn’t get much better than Redux. It allows you to define any number of reducers which comprise a global store, to dispatch actions to that store, and to subscribe to only the parts of state that you care about in each component.

On the other hand, for apps with relatively simple state, a few useState calls should do the trick. However, while working on a large frontend project recently, I noticed that our state grew complex enough to warrant something more than some hooks. No team at Yext has used Redux thus far, and even despite its relatively small size (163 kB), we thought it overkill for our use case. What we really wanted was a basic Redux implementation without having to use a third-party library.

So, like any Yexter would, we set out to make our own Redux! Albeit with a simpler set of features, we still created a store-like state with a way to dispatch actions to that store. Here’s how:

The Reducer

First, we defined a custom reducer in its own file. A reducer is simply a function that accepts a current state and an action, and based on that action, returns a new state. The Redux convention is that every action is an object which has, at the very least, a type property which is a string. In this post, we will demonstrate with a simple counter which we can increment, decrement, or set. Here’s what a simple reducer for the count state looks like:

// countReducer.js
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
const SET_COUNT = 'SET_COUNT';

export default function countReducer(state, action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1;
    case DECREMENT:
      return state - 1;
    case SET_COUNT:
      return action.count;
    default:
      throw new Error(`Action type ${action.type} not recognized`);
  }
}

Later, we’ll feed this function into a useReducer hook which will automatically take care of updating the state for us when we dispatch actions.

The Store

I use the term “store” lightly here, because in this light Redux implementation, the store is nothing more than state stored in a top-level component. However, it still behaves much like a store in that it serves as the single source of truth for the application (or subset of the application). Additionally, we can dispatch actions to this “store” from anywhere.

As mentioned previously, we create this “store” using the useReducer hook, which will give us a state as well as a dispatch function to update that state. The hook automatically takes care of updating the state whenever it receives an action.

// TopLevelComponent.jsx
import React, { useReducer } from 'react';
import countReducer from './countReducer';

export default function TopLevelComponent(props) {
  const [count, dispatch] = useReducer(countReducer, 0);

  return (
    <p>The current count is {count}.</p>
  );
}

The second argument to useReducer is the initial value of the state, which we will set to 0.

Now that we have created our “store”, we need to provide child components with a way to update that store in a convenient way.

Providing dispatch

To make our implementation even sleeker, we wanted to provide a way for child components to access the dispatch function without manually passing it to them. To accomplish this, we used React Context. First, we created a context in the reducer file:

// countReducer.js
import { createContext } from 'react';

export const CountContext = createContext(null);

Next, we imported that context into our top-level reducer in order to provide the dispatch function to all children:

// TopLevelComponent.jsx
import React, { useReducer } from 'react';
import countReducer, { CountContext } from './countReducer';

export default function TopLevelComponent(props) {
  const [count, dispatch] = useReducer(countReducer, 0);

  return (
    <CountContext.Provider value={dispatch}>
      {/* children */}
    </CountContext.Provider>
  );
}

Then, any child which requires the dispatch function can grab it via the useContext hook.

We didn’t make a way to provide the value of the state globally (as Redux usually does), but that could easily be accomplished by using another piece of context and its corresponding provider.

Action Creators

Manually constructing action objects in each component becomes tedious. For example, every time you wanted to set the count from a child component, you would have to do something like this:

// SomeChildComponent.jsx
dispatch({
  type: SET_COUNT,
  count: 5
});

Well, it’s not that tedious, but as the state grows increasingly complex, action creators become even more useful. We defined some action creators in our reducer file that other child components can import. These action creators take care of constructing the object for you given certain parameters. Here’s an example for the set count action:

// countReducer.js
export const setCount(count) {
  return {
    type: SET_COUNT,
    count
  };
}

Bring it All Together

Now, we can use the dispatch function to update the global state from any child component:

// ChildComponent.jsx
import React, { useContext, useState } from 'react';
import { CountContext, setCount } from '../reducer';

export default function ChildComponent(props) {
  const dispatch = useContext(Context);
  const [value, setValue] = useState(0);

  return (
    <div>
      <p>Enter a number:</p>
      <input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
      <button type="button" onClick={() => dispatch(setCount(value))}>
        Set Count
      </button>
    </div>
  );
}

Now, when a user clicks the “Set Count” button, the global count state will be updated. Furthermore, any child which subscribes to that global state will automatically see all dispatched updates!

Just remember: using Redux is cool, but making your own (basic) Redux is far cooler 😎.