UPDATE: Lukas Klinzing pointed out that React context is suboptimal concerning performance. (Here is an article, which explains in more detail.)
In my spare time, I am creating a url shortener (https://2.gd). For that I created a global store solely using React Hooks. I like to show you how I implemented it without using any external libraries. Note that the following example is only a lightweight alternative to redux and it is should not be considered as a replacement. For instance, redux still offers a lot of nice features like time travel debugging.
Table of Contents
Context
Context allows us to share data between components without explicitly passing down the props.
import React, { createContext } from 'react'
const LocaleContext = createContext({ language: 'jp' })
const { Provider, Consumer } = LocaleContext
function App(){
return (
<Provider value={{ language: 'ru' }}>
<Layout/>
</Provider>
)
}
function Layout(){
return (
<div>
<Consumer>
{value => (<span>I speak {value.language} </span>)}
</Consumer>
</div>
)
}
With the help of the React Hooks we can express the same code more concisely:
import React, { createContext, useContext } from 'react'
// ...
function Layout(){
const { language } = useContext(LocaleContext)
return (
<div>
<span>I speak {language} </span>
</div>
)
}
useReducer Hook
Using useReducer
Hook we can create a reducing/accumulating state:
const initialState = { isLoading: false }
function reducer(state, action) {
switch (action.type) {
case 'START_LOAD':
return { isLoading: true };
case 'COMPLETE_LOAD':
return { isLoading: false };
default:
throw new Error('I forgot a case');
}
}
function StartButton() {
const [state, dispatch] = useReducer(reducer, initialState);
return state.isLoading
? (<button onClick={() => dispatch({type: 'COMPLETE_LOAD'})}>Abort</button>)
: (<button onClick={() => dispatch({type: 'START_LOAD'})}>Start</button>)
)
}
Global Store
Let's combine the both knowledge about the Context and useReducer to create a global store.
The typings looks as follows:
import React, { Dispatch } from 'react'
type Context = { state: State; dispatch: Dispatch<Action> }
interface State {
items: Entry[]
isLoading: boolean,
error: string | null,
}
interface Entry {
name: string
}
// Discriminating Union
type Action =
| StartRequestAction
| SucceedRequestAction
| FailRequestAction
interface StartRequestAction {
type: 'START_REQUEST'
}
interface SucceedRequestAction {
type: 'SUCCEED_REQUEST'
payload: Entry
}
interface FailRequestAction {
type: 'FAIL_REQUEST'
payload: string
}
Let's call the new file store.tsx
:
import React, { createContext, useReducer, PropsWithChildren } from 'react'
const initialStoreContext: Context = {
state: {
items: [],
isLoading: false,
error: null,
},
dispatch: (_a) => {},
}
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'START_REQUEST':
return { ...state, isLoading: true, error: null }
case 'SUCCEED_REQUEST':
return {
...state,
items: [action.payload, ...state.items],
isLoading: false
}
case 'FAIL_REQUEST':
return { ...state, error: action.payload, isLoading: false }
default:
return assertNever(action)
}
}
const storeContext = createContext(initialStoreContext)
const { Provider } = storeContext
const StateProvider = ({ children }: PropsWithChildren<any>) => {
const [state, dispatch] = useReducer(reducer, initialStoreContext.state)
return <Provider value={{ state, dispatch }}>{children}</Provider>
}
export { storeContext, StateProvider }
We use a function called assertNever
in order to check if all variants of our union type Action
are handled. In other words, if we forget to handle a certain action like START_REQUEST
in switch case, then TypeScript compiler will report that StartRequestAction
cannot be assigned to type never
.
// Taken from https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-exhaustiveness-checking
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
Do not forget to wrap the root element with StateProvider:
import React from 'react'
import ReactDOM from 'react-dom'
import { StateProvider } from './store'
import App from './App'
ReactDOM.render(
<StateProvider>
<App />
</StateProvider>,
document.querySelector('#root')
)
Now we can simply access our state and dispatch actions. Thanks to discriminating union type Action
, our dispatch function is type-safe. Try to pass a object as payload in FAIL_REQUEST
action. The TypeScript compiler will complain that Type '{}' is not assignable to type 'string'.
import React, { useContext, useEffect } from 'react'
import { storeContext } from './store'
import axios from 'axios'
function Body(){
const { state } = useContext(storeContext)
const { isLoading, error, items } = state
return error
? (<p>An error has occurred</p>)
: isLoading
? (<p>Wait ... </p>)
: items.map(e => (<p>{e.name}</p>))
}
function Home() {
const { state, dispatch } = useContext(storeContext)
const { isLoading } = state
useEffect(() => {
const call = async () => {
try {
const response = await axios.get<Entry>('/api/v1/data/')
dispatch({ type: 'SUCCEED_REQUEST', payload: response.data })
} catch (err) {
const errorMsg = err && err.response ? err.response.data : ''
dispatch({ type: 'FAIL_REQUEST', payload: errorMsg })
}
}
if (isLoading) {
call()
}
}, [isLoading])
return (
<>
<button onClick={() => dispatch({ type: 'START_REQUEST' })}>Load Data</button>
<Body />
</>
)
}
Persistence
Modern browers provide many different storage mechanisms like LocalStorage or IndexedDB. Most people will recommend to use IndexedDB because LocalStorage is synchronous, can only save strings and is limited to about 5MB.
Nonetheless, we will use LocalStorage because there is a certain advantage over IndexedDB, which will be explained in the next chapter. (Furthermore, I noticed that LocalStorage does not work properly in Firefox.)
We will use the useEffect
hook to save data locally as soon as items are changed. So let's expand the StateProvider as follows:
const StateProvider = ({ children }: PropsWithChildren<any>) => {
const STORAGE_KEY = 'MY_DATA'
// load data initially
const [state, dispatch] = useReducer(reducer, initialStoreContext.state, (state) => {
const persistedData = localStorage.getItem(STORAGE_KEY)
const items = persistedData ? JSON.parse(persistedData) : []
return { ...state, items }
})
// save data on every change
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.items))
}, [state.items])
return <Provider value={{ state, dispatch }}>{children}</Provider>
}
Synchronization between Browser Tabs
You will quickly notice, once you have multiple tabs of your React app open, that they may end up in an unsynchronized state. In order to avoid that we can listen to changes of LocalStorage and update the state of each tab accordingly. Currently there is no way to listen to the changes of IndexedDB. That is why we use LocalStorage here.
First we add a new action:
interface StorageSyncAction {
type: 'SYNC_REQUEST'
payload: Entry[]
}
const reducer = (state: State, action: Action): State => {
switch (action.type) {
// ...
case 'SYNC_REQUEST':
return { ...state, items: action.payload }
default:
return assertNever(action)
}
}
Then we expand our StateProvider with LocalStorage listener:
const StateProvider = ({ children }: PropsWithChildren<any>) => {
const STORAGE_KEY = 'MY_DATA'
const [state, dispatch] = useReducer(reducer, initialStoreContext.state, (state) => {
const persistedData = localStorage.getItem(STORAGE_KEY)
const items = persistedData ? JSON.parse(persistedData) : []
return { ...state, items }
})
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.items))
}, [state.items])
// use the newest data on every LocalStorage change
useEffect(() => {
window.addEventListener('storage', () => {
const persistedData = localStorage.getItem(STORAGE_KEY)
const newData = persistedData ? (JSON.parse(persistedData) as Entry[]) : null
if (newData) {
dispatch({ type: 'SYNC_REQUEST', payload: newData })
}
})
}, [])
return <Provider value={{ state, dispatch }}>{children}</Provider>
}
References
- Window: storage event by MDN
- Getting events on IndexedDB updates from another tab
- Storage for the web by Pete LePage
- Unions and Intersection Types by Microsoft
- Context by Facebook
- Hooks API Reference by Facebook
- Do React Hooks Replace Redux? by Eric Elliott
- Use Hooks + Context, not React + Redux by Ebenezer Don
- Cover Image by LoggaWiggler from Pixabay
Top comments (1)
I would suggest to not use react context as a global single source of truth store. The reason is simply performance. Out of the box the smallest change in the store will make a basically the full page a rerender unless you do a lot of caching and optimization and memoized selectors etc. If you have two distinct objects like user-settings and theme-settings then put them into two different contexts. There is no way a profile is modifying the preferred color. So why triggered that change then. And if for some reason really a profile would change a preferred color, you can always being two consumers together and react accordingly.