Creating persistent, synchronized global store using React Hooks in TypeScript
Tags: react, typescript, webdev, javascript
Published: 2020-11-26 20:00:42

UPDATE: Lukas Klinzing pointed out that React context is suboptimal concerning performance. (Here is an [article](https://blog.logrocket.com/pitfalls-of-overusing-react-context/), 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)
* [useReducer Hook](#useReducer-hook)
* [Global Store](#global-store)
* [Persistence](#persistence)
* [Synchronization between Browser Tabs](#synchronization-between-browser-tabs)
* [References](#references)

## Context 
Context allows us to share data between components without explicitly passing down the props.
```tsx
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:
```tsx
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:

```tsx
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:
```tsx
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`:
```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`.
```tsx
// 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:
```tsx
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'.`
```tsx
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:
```tsx
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:
```tsx
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:
```tsx
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](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event)
* [Getting events on IndexedDB updates from another tab](https://stackoverflow.com/questions/55647788/getting-events-on-indexeddb-updates-from-another-tab)
* [Storage for the web by Pete LePage](https://web.dev/storage-for-the-web/)
* [Unions and Intersection Types by Microsoft](https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-exhaustiveness-checking)
* [Context by Facebook](https://reactjs.org/docs/context.html)
* [Hooks API Reference by Facebook](https://reactjs.org/docs/hooks-reference.html#usereducer)
* [Do React Hooks Replace Redux? by Eric Elliott](https://medium.com/javascript-scene/do-react-hooks-replace-redux-210bab340672)
* [Use Hooks + Context, not React + Redux by Ebenezer Don](https://blog.logrocket.com/use-hooks-and-context-not-react-and-redux/)
* Cover Image by [LoggaWiggler](https://pixabay.com/users/loggawiggler-15/?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=211501) from [Pixabay](https://pixabay.com/?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=211501)