Simple State Manager

May 31, 201810 minute read
  • reactjs
  • state
  • context

This post assumes a good understanding of React, and maybe a little familiarity with Redux

Intro

I recently started a new project and have been trying to figure out how to use React's new, stable Context API to handle any necessary global state.

I normally use Redux for this sort of thing, but I find the amount of boilerplate that Redux requires to be a bit cumbersome. There are actions, and reducers, and constants, and then you have to stitch all of that together and plug it into your application. Even when you are used to all of these mechanics it can still be more work than it's worth when you are starting a project that may not need all of that robustness.

That's not to say Redux does not have it's place in the ecosystem - it's a very well thought out, thorough solution for state management of an application. But it may be overkill for a lot of apps. Especially when the app is still a seedling. You don't need a huge pot before the roots start to spread.


Implementation

With all of that said, let's get to it. I will start off by creating a simple counter where a user clicks a button to increment a value. The counter value will be held in the global state and passed down to the components that need to consume it.

My goal is to have a simple component at the top level handling any global state that my application needs, and to be able to plug in deep components directly into that state.

The top-level component will look like the following:

<StateManager>
  <App />
</StateManager>

If you have any experience with Redux, you'll realize this will be equivalent to a Provider, except you'll also notice that we aren't passing any kind of store to it. We'll get to that in later.

If you have not yet looked into React's new Context API, I would go ahead and pause here to check out what that looks like. And then you can return to see how I have integrated it into my StateManager Component below.

const { Provider, Consumer } = React.createContext();

class StateManager extends Component {
  static Consumer = Consumer;

  state = { counter: 0 };

  increment = () => {
    this.setState(({ counter }) => ({
      counter: counter + 1
    }));
  };

  render() {
    return (
      <Provider
        value={{
          ...this.state,
          increment: this.increment
        }}
      >
        {this.props.children}
      </Provider>
    );
  }
}

In the first line we create the context with React.createContext().

We then create a React component with an initial state of { counter: 0 }. This will be the state that we want our deep components to be able to tap into.

We also have a function increment that allows us to mutate the state using React's built-in setState method. Notice we pass in a callback function, which allows the state mutation to be atomic - if you call it multiple times in a row, it won't incorrectly set the counter value to an inconcsistent value.

Lastly, what we actually render is whatever children are contained within our StateManager component wrapped by the Provider that we received from React's createContext function. We pass in the entire state into the Providers value prop, as well as any action functions we provide.

Note that I glossed over the part where we tied the Consumer to our StateManager component (static Consumer = Consumer;). This just allows us not to have to export Consumer individually and instead can use it in this way: StateManager.Consumer, as you'll see below.

Now that we have our StateManager component, let's create a component that consumes the counter value.

const CounterDisplay = ({ counter }) => (
  <div>{counter}</div>
);

const CounterDisplayContainer = (props) => (
  <StateManager.Consumer>
    {({ counter }) => (
      <CounterDisplay
       counter={counter}
       {...props}
      />
    )}
  </StateManager.Consumer>
);

Here, we just have a really simple component to display a counter value, and then have a container component that ties that simple component into our state from the StateManager.

We are using the Consumer component that was provided from React's createContext API, and providing a function as a child as described by the documentation. (I will show how we can simplify this to look more like Redux's connect function later on).

Similarly, we can do the same thing with the increment function, tying the action to the onClick prop of a button.

const CounterButton = ({ onClick }) => (
  <button onClick={onClick}>Increment</button>
);

const CounterButtonContainer = (props) => (
  <StateManager.Consumer>
    {({ increment }) => (
      <CounterButton
        onClick={() => increment()}
        {...props}
      />
    )}
  </StateManager.Consumer>
);

If you put all of this together, you can see the result below:

Initial Result


Enhancements

Now that we have a strong sense of how this can work, let's go ahead and add a few enhancements.

withState Function

You'll notice there is a lot of duplication using the StateManager.Consumer component, and a function as a child isn't that appealing. (I believe that's why people like render functions so much).

So let's create a new function, withState, that effectively works similar to Redux's connect function. It will wrap our component and allow us to map the state from our StateManager to the props of the simple component being connected.

First, let's start with what we want the function to look like when applied:

const mapStateToProps = ({ counter }) => ({ counter });

ConnectedCounterDisplay = withState(mapStateToProps)(CounterDisplay);

Similarly, we can do the same for the button:

const mapStateToProps = ({ increment }) => ({
  onClick: () => increment()
});

ConnectedCounterButton = withState(mapStateToProps)(CounterButton);

Notice how the action function is within the global state. With this solution, the state contains the actions, instead of needing to wrap action functions with Redux's dispatch function.

Now, let's actually create the implementation for the withState function wrapper:

const { Provider, Consumer } = React.createContext();

class StateManager extends Component {
  static Consumer = Consumer;
  // ... Code Removed for Brevity
}

function withState(stateMapperFn) {
  return (InnerComponent) => {
    const ConnectedComponent = () => (
      <StateManager.Consumer>
        {(state) => (
          <InnerComponent
            {...stateMapperFn(state)}
            {...this.props} // this allows props passed into the Component
                            // to override any state values
          />
        )}
      </StateManager.Consumer>
    );

    // This just adds syntactical sugar for the React debugger.
    ConnectedComponent.displayName = `withState(${InnerComponent.displayName ||
      InnerComponent.name})`;

    return ConnectedComponent;
  };
}

It may seem like there is a lot going on here, but it's very similar to what we were doing before. We are just wrapping a component with Consumer from the created context, applying the mapped state to the props of that component.

Initial State

Let's also go ahead and provide a way to pass initialState from the top-level component

<StateManager initialState={{ counter: 5 }}>
  <App />
</StateManager>

We can make this work by just spreading this object into the initial state of the StateManager component.

const { Provider, Consumer } = React.createContext();

class StateManager extends Component {
  static Consumer = Consumer;

  state = {
    counter: 0,
    ...this.props.initialState
  };

  // ... Code Removed for Brevity
}

Notice that we still want to provide default state values (setting counter to 0), but this way we can override those values with ones we want from the initialState object passed in.

Final Result

Below you can examine the final result of all of this code put together.

Conclusion

This was just a simple way you can integrate React's new Context API into your application to provide a global state, along with actions to mutate it, all within a single component.

I wouldn't go out and rip Redux from all of your applications just yet. It still holds a lot of value, especially with state snapshots and action replay. But for applications that just need a simple state manager, this is the way to go in my opinion.

There are also some caveats to this appraoch that can be fixed, but haven't been demonstrated here, like optimizing the connected components' shouldComponentUpdate lifecycle method.

You can also use the StateManager component to aggregate different context providers, similar to the examples in the React documentation for context, but I plan on just leaving everything within a single component personally.


Thanks for reading!

Was this useful to you? If you have any questions or comments, feel free to use my Contact Form to send me a message, or tweet and follow me on Twitter.