Manage React state without redux
How to use React Context API in an easier and prettier way.
Disclaimer: I use Redux with React in production and I’m pretty happy with it.
TL;DR;
You suck! You just want the code but, if you’re in a hurry, download the repo or install the npm library in your project.
By the end of this article, you’ll learn how to read, write and remove data from one Component to another of your React app in a fast and easy way.
Edit November 2019
I published a version of this story with React Hooks, which is more modern and updated. Follow this link to read it:
Intro
A few weeks ago I started a project with React and I didn’t want to use Redux because the project was a small one. If you are wondering the reason why I didn’t want to use redux, let’s have a look at its architecture and flows.
Redux
If you are familiar with redux, just skip this section, that a may sound a little boring for you.
In redux you have to set up and maintain:
- Provider: The
<Provider />
makes the Reduxstore
available to any nested components that have been wrapped in theconnect()
function. - connect: The
connect()
function connects a React component to a Redux store. - middlewares: Middlewares provide a third-party extension point between dispatching an action, and the moment it reaches the reducer.
- actions: Actions are payloads of information that send data from your application to your store. They are the only source of information for the store. You send them to the store using
store.dispatch()
. - reducers: Reducers specify how the application’s state changes in response to actions sent to the store. Remember that actions only describe what happened, but don’t describe how the application’s state changes.
Not to mention that you have to create the redux Store with createStore
, concatenate reducers with combineReducers
, applyMiddlewares
such as redux-thunk. Then you have to connect
your Components with the state and the actions, using mapStateToProps
, mapActionsToProps
and sometimes mapDispatchToProps
.
This means that if you decide to use redux in your project on Tuesday morning, you’ll pass the rest of the morning setting it up. If you have already used redux in another project, maybe you can save some time copying/pasting the code and start working just in a few hours.
Next, you have to decide which is the best place to put the business logic: Components? Actions? Reducers? Middlewares?
After three months, when you discover a bug in your app and re-open the codebase, you have to remember what did that reducer do and why the state isn’t changing as expected.
This was not to scare you, just my experience 😃.
What we want to do
The goal of this article is to find a way to reduce (pun intended) the complexity of our applications. The image below is a spoiler of the final architecture we want to achieve.
Back to the intro
After the above considerations, I came up with the decision of using the React Context API.
Context provides a way to pass data through the component tree without having to pass props down manually at every level.
It accomplishes the goal of sharing a global state (or store) or whatever you wanna call it, but I find it very verbose and not really straightforward to use.
The goal of this article is to make the Global application state management easy to use (for developers). You will access the global store using store.set(’a_key’, 'a value’)
in one Component and read that value using store.get(’a_key’)
in another Component.
In other words, we are going to create a global key/value map to store data in, that re-renders your Components when its values change.
The first thing to do is to create a Context
, Context.Provider
and a Context.Consumer
. The code is:
import React, { Component } from 'react'const StoreContext = React.createContext()class MyComponent extends Component {
static contextType = StoreContext
//this line does the magic, binding this.context to the value of the Provider render() {
return <div>Hello {this.context.name}</div>
}
}const MyApp = props => (
<div>
<MyComponent />
</div>
)class AppWithContext extends Component {
state = { name: 'Spyna' } render() {
return (
<StoreContext.Provider value={this.state}>
<MyApp />
</StoreContext.Provider>
)
}
}export default AppWithContext
In this code snippet, you created a React Context
then you wrapped MyApp Component into the context Provider
and finally, you have bound MyComponent with the context using static contextType = StoreContext
.
Since we set the value property of StoreContext.Provider
to this.state
(that is the state of AppWithContext
), when something changes in that state, the AppWithContext
will update, causing the re-render of its children.
To make these changes happen, you want some context (no pun intended) methods to: set
, get
, and remove
data from the state. We used the AppWithContext state
as the provider value, so we’ll to add these methods to it, that translated into code means:
class AppWithContext extends Component {
state = {
get: (key) => {
return this.state[key]
},
set: (key, value) => {
const state = this.state
state[key] = value
this.setState(state)
},
remove: key => {
const state = this.state
delete state[key]
this.setState(state)
}
}...}
The provider value is this.state
, so we added our methods there. You certainly noticed that the property name
no longer exists in AppWithContext state
, and we need to add it using the fresh, new and fabulous methods we’ve just created and added to the state. To test if this work, we’ll set the name
property in MyComponent using the set
method and we’ll read it in the render method using get
.
class MyComponent extends Component {
static contextType = StoreContext
componentDidMount() {
let context = this.context
context.set('name', 'Spyna')
//sets the property 'name' with the value 'Spyna'
}
render() {
return <div>Hello {this.context.get('name')}</div>
//reads the property 'name' from the context
}
}
Yes, it works!
When the Component “did mount” we call context.set(’name’, ‘Spyna’)
to set the property “name” in the context. Since the context is modified, the Component updates and when the render method is called, the value of “name” is printed. If you run this code you’ll see “Hello Spyna” printed on your screen.
This code doesn’t look pretty as I like. What we want to do is to make it a more declarative and easy to use. To achieve this goal we are going to create two High Order Component: one for the context Provider
and the other one for the context Consumer
.
According to React docs, a High Order Component is:
A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, per se. They are a pattern that emerges from React’s compositional nature.
Concretely, a higher-order component is a function that takes a component and returns a new component.
const EnhancedComponent = higherOrderComponent(WrappedComponent);
If you want to know more about HOC have a look here.
Store Provider HOC
const createStore = WrappedComponent => {
return class extends React.Component {
state = {
get: key => {
return this.state[key]
},
set: (key, value) => {
const state = this.state
state[key] = value
this.setState(state)
},
remove: key => {
const state = this.state
delete state[key]
this.setState(state)
}
}
render() {
return (
<StoreContext.Provider value={this.state}>
<WrappedComponent {...this.props} />
</StoreContext.Provider>
)
}
}
}
If this code looks strange for you, have a look at the version without arrow functions =>
: https://codesandbox.io/s/l21x87543z?autoresize=1&hidenavigation=1&module=%2Fsrc%2Fstore.js
The function createStore
returns a Component that renders any Component passed as an argument inside the context Provider.
To use this HOC, all you have to do is just:
export default createStore(MyApp);
Store Consumer HOC
const withStore = WrappedComponent => {
return class extends React.Component {
render() {
return (
<StoreContext.Consumer>
{context => <WrappedComponent store={context} {...this.props} />}
<StoreContext.Consumer>
)
}
}
}
The function withStore
returns a Component that renders any Component you passed as an argument inside the context Consumer. Plus it injects a prop called store
into your Component, that is the context value.
Now you can access the context, doing:
const MyComponent = (props) => <div>{props.store.get('data')}</div>export default withStore(MyComponent)
Putting all together
Let’s create a file called store.js
, add the two HOC and export them.
import React from 'react'const StoreContext = React.createContext()const createStore = WrappedComponent => {
return class extends React.Component {
state = {
get: key => {
return this.state[key]
},
set: (key, value) => {
const state = this.state
state[key] = value
this.setState(state)
},
remove: key => {
const state = this.state
delete state[key]
this.setState(state)
}
}
render() {
return (
<StoreContext.Provider value={this.state}>
<WrappedComponent {...this.props} />
</StoreContext.Provider>
)
}
}
}const withStore = WrappedComponent => {
return class extends React.Component {
render() {
return (
<StoreContext.Consumer>
{context => <WrappedComponent store={context} {...this.props} />}
</StoreContext.Consumer>
)
}
}
}export { createStore, withStore }
And finally, use the HOCs, with the below code, that looks much prettier than it looked in the beginning:
// MyComponent.js
import React, { Component } from 'react'
import { withStore} from './store';class MyComponent extends Component {
componentDidMount() {
this.props.store.set('name', 'Spyna')
}render() {
return <div>Hello {this.props.store.name}</div>
}
}export default withStore(MyComponent)// App.js
import React, { Component } from 'react'
import { createStore} from './store'
import MyComponent from './MyComponent'
//import MyOtherWithStoreComponent from './MyOtherComponent'const MyApp = props => (
<div>
<MyComponent />
{/*<MyOtherWithStoreComponent />*/}
</div>
)export default createStore(MyApp)
Considerations
What you’ve done is just the beginning of a journey managing the state of your app. You may want to add some features to the store
you already created. For example, you want to:
- set an initial value, maybe reading it from the local storage or from an API:
createStore(MyApp, initialValue)
. - get a default value if there are no data in the store:
store.get('my_key', defaultValue)
. - provide immutability to the store methods, to avoid doing any damage by mistake, for example, if you call
store.get = () => 'hey I just broke everything'
you’ll break theget
method. - isolate store methods from its values: now you can access a value from the store either doing
store.get(’my_key’)
orstore.my_key
. This is not bad at all, maybe you want to make this behavior configurable. - make the store methods returns a Promise so that you can use:
store.remove(’my_key’).then(() =>{ doSomething() })
.
All these ideas are great and you may think it takes a lot of time to implement them, that’s why I created the project react-store that already does all those stuff for you. The codebase is very small and if you want to copy it, have a look at the repo linked below.
As I stated at the beginning I’m a fan of Redux and think it is very useful, mostly when you deal with big projects. However, I find it a bit overkilling and uncomfortable to set up in small projects.
One thing I found working with this lib is that thing could turn a little messy unless you define some patterns or rules. An example will explain this better: our state (or store) could be modified from any Component in the project. This means that you could use store.set('logged_user', user)
or store.get('logged_user')
in multiple places (You can do this also with redux, it’s just a little bit more verbose). Let’s take this project structure as an example:
└── src
└── features
├── gallery
├── layout
│ ├── footer
│ └── header
├── login
├── navigation
│ ├── menu
│ └── sideNavigation
└── search
Each folder under features
contains a feature of the project:
- the header Component (that displays the user name) uses
store.get('logged_user').
- The side navigation Component (that changes the menu items when you log in/out) uses
store.get('logged_user')
. - The login Component (that log the user in/out) uses
store.set('logged_user')
.
The point is that you have to remember in each Component that you saved the logged user information in the store with the key ‘logged_user
’.
The solution to this problem is to centralize that information and to do that you can create a storeMap.js
file, where you put a mapping for each key you’re using within your store. This file will look as below:
export default {
LOGIN : {
LOGGED_USER : 'login/logged_user', //who is logged
ACCESS_TOKEN : 'login/access_token' //user token to call our API
},
SEARCH : {
SEARCH_TERM : 'search/search_term', //the term you searched
SEARCH_RESULTS : 'search/search_result', //search results
CURRENT_PAGE : 'search/current_page' // the current page
}
}
The Components will now use store.get(StoreMap.LOGIN.LOGGED_USER)
instead of store.get('logged_user')
which is:
- less error-prone: you don’t have to write a string
- centralized: the whole App state is mapped in a single file (that may be split into more files using import/export if needed)
- easy to refactor: Just rename from your super IDE a key or a value.
- self-explicative: When looking at this file you can have an idea of the whole application state
Congratulations!! 😎 You made it to the end. And if you liked 👌this article, hit that clap button below 👏. It means a lot to me and it helps other people see the story.
More posts by spyna.