Cover image by Bryan Goff
To see the code for these examples, click here
There are seemingly endless ways of dealing with state management in React. Trying to understand the options, the tradeoffs between them, and how they work can be overwhelming.
When I'm trying to learn something, seeing a side by side comparison implementing some common real-world functionality helps me understand the differences between various options as well as form a mental model around how I may use them in my own applications.
In this post I'm going to walk through how to implement global state management in a React application using the same pattern across 5 of the most popular libraries and APIs using the most modern and up-to-date versions of the libraries.
I'll also try to explain the differences between them as well as my thoughts about and a general overview of each approach.
To demonstrate the APIs we'll be implementing a notes app using each library / approach that shows how to do create and list an array of notes.
Getting started
If you'd like to follow along, create a new React app that we'll be using for testing these approaches:
npx create-react-app react-state-examples
cd react-state-examples
To run the app at any time, run the start
command:
npm start
Recoil
Lines of code: 30
One of the things I really liked about Recoil was the hooks-based API and how intuitive it was go get going with.
When compared to some of the other options, I would say that the setup and API with recoil is easier than most.
Recoil in action
To get started with Recoil, install the library as a dependency:
npm install recoil
Next, add the RecoilRoot to the root / entry-point of the app:
import App from './App'
import { RecoilRoot } from 'recoil'
export default function Main() {
return (
<RecoilRoot>
<App />
</RecoilRoot>
);
}
Next, to create some state we will use an atom
from Recoil and set a key as well as some initial state:
import { atom } from 'recoil'
const notesState = atom({
key: 'notesState', // unique ID (with respect to other atoms/selectors)
default: [], // default value (aka initial state)
});
Now you can use useRecoilState
from Recoil to access this value anywhere in your app. Here is the notes app implemented using Recoil:
import React, { useState } from 'react';
import { RecoilRoot, atom, useRecoilState } from 'recoil';
const notesState = atom({
key: 'notesState', // unique ID (with respect to other atoms/selectors)
default: [], // default value (aka initial state)
});
export default function Main() {
return (
<RecoilRoot>
<App />
</RecoilRoot>
);
}
function App() {
const [notes, setNotes] = useRecoilState(notesState);
const [input, setInput] = useState('')
function createNote() {
const notesArray = [...notes, input]
setNotes(notesArray)
setInput('')
}
return (
<div>
<h1>My notes app</h1>
<button onClick={createNote}>Create Note</button>
<input value={input} onChange={e => setInput(e.target.value)} />
{ notes.map(note => <p key={note}>Note: {note}</p>) }
</div>
);
}
Recoil selectors
From the docs:
Selectors are used to calculate derived data that is based on state. This lets us avoid redundant state, usually obviating the need for reducers to keep state in sync and valid. Instead, a minimal set of state is stored in atoms, while
Using Recoil selectors you can compute derived data based on your state, for instance maybe a filtered array of todos that are completed (in a todo app), or an array of orders that have been shipped (in an e-commerce app):
import { selector, useRecoilValue } from 'recoil'
const completedTodosState = selector({
key: 'todosState',
get: ({get}) => {
const todos = get(todosState)
return todos.filter(todo => todo.completed)
}
})
const completedTodos = useRecoilValue(completedTodosState)
Verdict
The recoil docs say that "Recoil is an experimental set of utilities for state management with React.". When I hear the word "experimental" it doesn't make me extremely comfortable when I'm making the decision to use a library in production, so I'm unsure how I feel about doing so now with Recoil, at least at the moment.
Recoil is awesome and I'd jump on it for my next app but am worried about the experimental
label so I will be keeping an eye on it but not using it for anything in production right now.
MobX
Lines of code: 30
MobX React has always been one of my favorite ways to manage React state, mainly because it was the next thing I tried after using Redux. The stark difference for me between the two cemented it for me as my go-to option over the years.
MobX React now has a light version (MobX React Lite) that is made especially for functional components and is slightly faster and smaller.
MobX has the idea of observables and observers, but the observable API has changed a bit and you do not have to specify each item that you'd like to be observable, instead you can use makeAutoObservable
which will handle everything for you.
If you want your data to be reactive
and subscribed to changes in the store, then you wrap the component using it in an observer
.
MobX in action
To get started with MobX, install the library as a dependency:
npm install mobx mobx-react-lite
The state for the app is created and managed in Stores.
The store for our app looks like this:
import { makeAutoObservable } from 'mobx'
class NoteStore {
notes = []
createNote(note) {
this.notes = [...this.notes, note]
}
constructor() {
/* makes all data in store observable, replaces @observable */
makeAutoObservable(this)
}
}
const Notes = new NoteStore()
We can then import the Notes
and use them anywhere in our app. To make a component observe changes, you wrap it in an observer
:
import { observer } from 'mobx-react-lite'
import { notes } from './NoteStore'
const App = observer(() => <h1>{notes[0]|| "No notes"}</h1>)
Let's see how all of it works together:
import React, { useState } from 'react'
import { observer } from "mobx-react-lite"
import { makeAutoObservable } from 'mobx'
class NoteStore {
notes = []
createNote(note) {
this.notes = [...this.notes, note]
}
constructor() {
makeAutoObservable(this)
}
}
const Notes = new NoteStore()
const App = observer(() => {
const [input, setInput] = useState('')
const { notes } = Notes
function onCreateNote() {
Notes.createNote(input)
setInput('')
}
return (
<div>
<h1>My notes app</h1>
<button onClick={onCreateNote}>Create Note</button>
<input value={input} onChange={e => setInput(e.target.value)} />
{ notes.map(note => <p key={note}>Note: {note}</p>) }
</div>
)
})
export default App
Verdict
MobX has been around for a while and is tried and true. I've used it in massive production applications at enterprise companies as have many others.
After using it again recently I feel like the documentation was slightly lacking compared to some of the other options. I'd try it out for yourself to see what you think before making a bet on it.
XState
Lines of code: 44
XState is trying to solve the problem of modern UI complexity and relies on the idea – and an opinionated implementation of – finite state machines.
XState was created by David Khourshid, who I have seen talking alot about it since it was released so I have been eager to give it a shot for a while. This is the only library here that I was unfamiliar with before writing this post.
After trying it out, I can say for sure that it is a much different approach than any of the others. The complexity here is more than any of the others, but the mental model of how state works is really cool and empowering, and made me feel smart after getting it working and building a few example apps with it 🧠.
To learn more about the problems that XState is trying to solve, check out this video from David Khourshid or this post which I also found interesting.
XState does not translate especially well here as it really shines with more complex state, but this light introduction will at least hopefully give you an introduction to help you wrap your mind around how it all works.
XState in action
To get started with XState, install the libraries:
npm install xstate @xstate/react
To create a state machine you use the Machine
utility from xstate
. Here is the machine we will be using for the Notes app:
import { Machine } from 'xstate'
const notesMachine = Machine({
id: 'notes',
initial: 'ready',
context: {
notes: [],
note: ''
},
states: {
ready: {},
},
on: {
"CHANGE": {
actions: [
assign({
note: (_, event) => event.value
})
]
},
"CREATE_NOTE": {
actions: [
assign({
note: "",
notes: context => [...context.notes, context.note]
})
]
}
}
})
The data we will be working with is stored in the context
object. Here, we have the array of notes as well as a note that will be controlled by a text input. There are two actions, one for creating a note (CREATE_NOTE
) and one for setting the text input (CHANGE
).
Putting it all together:
import React from 'react'
import { useService } from '@xstate/react'
import { Machine, assign, interpret } from 'xstate'
const notesMachine = Machine({
id: 'notes',
initial: 'ready',
context: {
notes: [],
note: ''
},
states: {
ready: {},
},
on: {
"CHANGE": {
actions: [
assign({
note: (_, event) => event.value
})
]
},
"CREATE_NOTE": {
actions: [
assign({
note: "",
notes: context => [...context.notes, context.note]
})
]
}
}
})
const service = interpret(notesMachine).start()
export default function App() {
const [state, send] = useService(service)
const { context: { note, notes} } = state
return (
<div>
<h1>My notes app</h1>
<button onClick={() => send({ type: 'CREATE_NOTE' })}>Create Note</button>
<input value={note} onChange={e => send({ type: 'CHANGE', value: e.target.value})} />
{ notes.map(note => <p key={note}>Note: {note}</p>) }
</div>
)
}
To subscribe to state changes across the app, we use the useService
hook from xstate-react
.
Verdict
XState is like the Rolls Royce or Swiss Army Knife of state management. There is a ton you can do, but all of the power comes with additional complexity.
I look forward to learning and understanding it better in the future so I can apply it to problems and reference architectures here at AWS, but for small projects I think it may be overkill.
Redux
Lines of code: 33
Redux is one of the earliest and most successful state management libraries in the entire React ecosystem. I've used Redux in countless projects and it still is going strong today.
The new Redux hooks API makes redux boilerplate somewhat less of an issue and a lot easier to work with.
Redux Toolkit has also improved the DX as well as lowered the learning curve a lot from what it was in the past.
Redux in action
To get started with Redux, install the necessary libraries:
npm install @reduxjs-toolkit react-redux
To work with Redux, you need to create and configure the following:
- A store
- Reducers
- A provider
To help explain how all of this works, I've made comments in the code that implements the Notes app in redux:
import React, { useState } from 'react'
import { Provider, useDispatch, useSelector } from 'react-redux'
import { configureStore, createReducer, combineReducers } from '@reduxjs/toolkit'
function App() {
const [input, setInput] = useState('')
/* useSelector allows you to retrieve the state that you'd like to work with, in our case the notes array */
const notes = useSelector(state => state.notes)
/* dispatch allows us to send updates to the store */
const dispatch = useDispatch()
function onCreateNote() {
dispatch({ type: 'CREATE_NOTE', note: input })
setInput('')
}
return (
<div>
<h1>My notes app</h1>
<button onClick={onCreateNote}>Create Note</button>
<input value={input} onChange={e => setInput(e.target.value)} />
{ notes.map(note => <p key={note}>Note: {note}</p>) }
</div>
);
}
/* Here we create a reducer that will update the notes array when the `CREATE_NOTE` action is dispatched */
const notesReducer = createReducer([], {
'CREATE_NOTE': (state, action) => [...state, action.note]
})
/* Here we create the store using the reducers in the app */
const reducers = combineReducers({ notes: notesReducer })
const store = configureStore({ reducer: reducers })
function Main() {
return (
/* Here we configure the Provider with the store */
<Provider store={store}>
<App />
</Provider>
)
}
export default Main
Verdict
Redux is a really solid choice if you're looking something with a massive community and a large amount of documentation and answers. Because it has been around for so long, you can pretty much Google any question and at least get a somewhat relevant answer.
When working with async operations like data fetching you typically need to add additional middleware which adds additional boilerplate and complexity.
To me, Redux was hard to learn at first. Once I became familiar with the framework it was really easy to work with and understand. In the past it was sometimes overwhelming for new developers, but with the recent improvements made with Redux hooks and Redux Toolkit, the learning curve is much easier and I still highly recommend Redux as a first-class option.
Context
Lines of code: 31
The great thing about context is that there's no libraries to install and keep up to date, it's just part of React. There are a ton of examples of how to use it, and it's documented right there along with the rest of the React documentation.
Working with context is pretty straightforward, the problem often arises in a larger or more complex application when you're trying to manage a large number of different context values so you will often have to build your own abstractions to manage these situations yourself.
Context in action
To create and use context, import the hooks directly from React. Here is how it works:
/* 1. Import the context hooks */
import React, { useState, createContext, useContext } from 'react';
/* 2. Create a piece of context */
const NotesContext = createContext();
/* 3. Set the context using a provider */
<NotesContext.Provider value={{ notes: ['note1', 'note2'] }}>
<App />
</NotesContext.Provider>
/* 4. Use the context */
const { notes } = useContext(NotesContext);
Putting it all together:
import React, { useState, createContext, useContext } from 'react';
const NotesContext = createContext();
export default function Main() {
const [notes, setNotes] = useState([])
function createNote(note) {
const notesArray = [...notes, note]
setNotes(notesArray)
}
return (
<NotesContext.Provider value={{ notes, createNote }}>
<App />
</NotesContext.Provider>
);
}
function App() {
const { notes, createNote } = useContext(NotesContext);
const [input, setInput] = useState('')
function onCreateNote() {
createNote(input)
setInput('')
}
return (
<div>
<h1>My notes app</h1>
<button onClick={onCreateNote}>Create Note</button>
<input value={input} onChange={e => setInput(e.target.value)} />
{ notes.map(note => <p key={note}>Note: {note}</p>) }
</div>
);
}
Verdict
Context is a really solid and straightforward way to manage state in your app. The API may not be as nice as some of the other options, but if you understand how to use it and can create the right abstraction with it in your app, you can't really go wrong with choosing context to manage global state in your app.