DEV Community

Cover image for Creating persistent, synchronized global store using React Hooks in TypeScript
Arooran Thanabalasingam
Arooran Thanabalasingam

Posted on • Edited on

Creating persistent, synchronized global store using React Hooks in TypeScript

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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>)

    )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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')
)
Enter fullscreen mode Exit fullscreen mode

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 />
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

References

Top comments (1)

Collapse
 
theluk profile image
Lukas Klinzing

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.