Simple State Manager
- 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 Provider
s 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.