Advanced management of the global state of your react app with less code without using external state management libraries like redux

In this blog, we will discuss the problem of using an external state management library like redux and find a way at the end to make it seamless with native React’s context api.

The Problem:

  • Boilerplate code.
  • DX issue.
  • Maintaining.
  • TS support.

The Solution:

  • Use of React’s context api.
  • Reduce Complexity.
  • Enhance DX.
  • TS support.
  • Creating performant typesafe context’s in minutes with react-store-maker.

People seem to love Redux and hate it at the same time. It’s a lot of boilerplate code for your codebase and maintaining it is always a pain to be considered.

Love and hate

After building a few Redux and non-Redux React Applications I want to share my experience so that, you can choose well for your next app.

Web apps are getting more complex and data-driven day by day. We need to think about the architecture of our frontend apps based on Two things.

  • Client Side State.
  • Server Side State.

In this blog, we will be exploring client-side solutions for state management and it’s going to be fun believe me.

Client-side states are just the states that are non-persistent. It goes away with a page refresh.

If you have ever used react in your project you will understand how much pain prop drilling is. To avoid this problem we often introduce 3rd party state management libraries like Redux.

Thankfully react supports Context-API for sharing states between components. But, always there is a but.

Context-API can leave you with a performance bottleneck if not used properly. If any of the data changes inside context the components that are subscribing to it will re-render.

Let’s write a sub-optimal context for our entire app.

type Theme = "light" | "dark";

const init = {
    theme: "dark" as Theme,
    user: null as null | { name: string; token: string },
    setStore: React.Dispatch<Partial<State>>
};

const Context = React.createContext(init);

export type State = typeof init;

export const MainContext: React.FC = (props) => {
    const [store, setStore] = React.useReducer(
        (state: State, newState: Partial<State>) => {
            return { ...state, ...newState };
        },
        init
    );

    return (
        <Context.Provider value={{ ...store, setStore }}>
            {props.children}
        </Context.Provider>
    );
};

We created our context. One in all for the store and dispatching with setState.

This way we can not optimize our context. The component subscribing to the store context will re-render for state updates.

—————————————————————–

We place it at the top of the tree and can consume it in our app anywhere in its context.

export const App = () => {
    return (
        <MainContext>
            <Header />
            <Main />
            <Footer />
        </MainContext>
    );
};

—————————————————————–

Now let’s consume it in the <Header/> & <Main/>.

Just like good old days we consume with useContext hook.

Consuming the contexts – Context

const Header = () => {
+   const store = React.useContext(Context);

    return (
        <header>
            <button
                onClick={() => {
                    store.setStore({
                        theme: 
                          store.theme === "light" ? "dark" : "light",
                    });
                }}
            >
                {store.theme}
            </button>
        </header>
    );
};

const Main = () => {
+   const store = React.useContext(Context);

    return (
        <main>
            <h1>User {store.user?.name}</h1>
        </main>
    );
};

For accessing the store, we had to get context and use it with useContext hook passing it as an argument.

—————————————————————–

Let’s make custom hooks for non-repeating logic and by the way who doesn’t love hooks 😻.

const useStore = () => {
    const store = React.useContext(Context);

    if (!store) {
        throw new Error("useStore must be used within StoreContext");
    }

    return store;
};

Now we have a custom hook dedicated to our store and updating the store

—————————————————————–

Let’s replace <Header/> with our hooks:

const Header = () => {
-   const store = React.useContext(Context);

+   const store = useStore();

Yay, we replaced redux with context. But remember everything comes with a cost.

If we subscribe to the context in any components of our Application, for any updates to the context, it will cause re-renders.

Therefore, Our app will suffer from a performance bottleneck. So, What can we do 🤔!

One solution to the problem we can come to is using multiple contexts in our application. Hence not all components will not be subscribed to the same context to access data.

By doing this, we get a few benefits.

  • Separation of logic
  • Maintainable codebase
  • Scalability
  • Performance

Enough talk. Let’s write some code. Let’s separate our contexts. The first one is theme context

/**
 * theme context example
 */
const initTheme = {
    theme: "dark" as Theme,
};

const ThemeContext = React.createContext(initTheme);

export type ThemeState = typeof initTheme;

const ThemeActionContext = React.createContext<
    React.Dispatch<Partial<ThemeState>>
>(() => {});

export const ThemeContextProvider: React.FC = (props) => {
    const [store, setStore] = React.useReducer(
        (state: ThemeState, newState: Partial<ThemeState>) => {
            return { ...state, ...newState };
        },
        init
    );

    return (
        <ThemeContext.Provider value={{ ...store }}>
            <ThemeActionContext.Provider value={setStore}>
                {props.children}
            </ThemeActionContext.Provider>
        </ThemeContext.Provider>
    );
};

This time we create multiple contexts for the theme store and updating the theme store

—————————————————————–

Our custom hooks for theme access look like this:

const useThemeStore = () => {
    const store = React.useContext(ThemeContext);

    if (!store) {
        throw new Error("useThemeStore must be used within ThemeContext");
    }

    return store;
};

const useThemeDispatch = () => {
    const dispatch = React.useContext(ThemeActionContext);

    if (!dispatch) {
        throw new Error(
            "useThemeDispatch must be used within ThemeActionContext"
        );
    }

    return dispatch;
};

—————————————————————–

We have to do the Same for the user context.

Let’s assume we made our separate context for User .

Also, let’s assume we also made our custom useUserStore, useUserDispatch hook.

Finally, we wrap our app with contexts

const App = () => {
    return (
        <ThemeContext>
            <UserContext>
                <Header />
                <Main />
                <Footer />
            </UserContext>
        </ThemeContext>
    );
}

As we can see that we wrapped our app with contexts.

No order needs to be followed as far as we make use of the other context in our ContextProvioder

—————————————————————–

Now it’s time to consume the contexts in our components.

const Header = () => {
-   const store = useStore();
-   const storeDispatch = useDispatch();
    
+   const store = useThemeStore();
+   const storeDispatch = useThemeDispatch();
const Main = () => {
-   const store = useStore();

+   const store = useUserStore();

Here, now we are consuming different contexts in <Header/> and <Main/> components.

Hence, triggering the dispatch in the <Header/> theme toggling will not re-render the <Main/> component.

By this, we optimize our context consumer components and prevent unnecessary re-renders.

But, every time for creating a new context and getting up and running there’s a lot of boilerplate code we have to write.

Too much code

To solve this problem I published a package to npm called react-store-maker. Which basically is a simple and tiny utility function that helps us to create context-based stores in seconds.

While initializing we pass an initial state and a reducer function as arguments and it returns an array (3) of [Context, store hook, store dispatch hook] and it is typesafe.

In-depth information on react-store-maker on https://github.com/adiathasan/react-store-maker.

Now let’s replace our legacy boilerplate codes.

import {createStore} from 'react-store-maker'

—————————————————————–

We initialize theme configs by calling createStore.

We pass the initial value and reducer function as an argument


import { createStore } from 'react-store-maker';

export type Theme = 'light' | 'dark';

const init: Theme = 'light';

export type ThemeActions = 
| { type: 'SET_DARK'; payload: 'dark' } 
| { type: 'SET_LIGHT'; payload: 'light' };

const reducer = (state: Theme = init, action: ThemeActions) => {
	switch (action.type) {
		case 'SET_LIGHT':
			return action.payload;
		case 'SET_DARK':
			return action.payload;
		default:
			return state;
	}
};

const [ThemeProvider, useThemeStore, useThemeDispatch] = 
                 createStore<Theme, ThemeActions>(init, reducer);

export { ThemeProvider, useThemeStore, useThemeDispatch };

Similarly, we create UserContext by calling createStore function and have access to useUserStore, useUserDispatch hooks.

So easy and painless to set it up right?

Easy Peasy

Now we wrap the ThemeContext and UserContext in App.tsx like before

import { ThemeContext } from '../theme/themeConfig.ts'
import { UserContext } from '../user/userConfig.ts'

const App = () => {
    return (
        <ThemeContext>
            <UserContext>
                <Header />
                <Main />
                <Footer />
            </UserContext>
        </ThemeContext>
    );
};

—————————————————————–

Same as before, we now just replace our own made hooks.

Using hooks that came from createStore function. e.g. themeConfig.ts, userConfig.ts


import {useThemeStore, useThemeDispatch} from '../theme/themeConfig.ts' 

const Header = () => {
  const themeStore = useThemeStore();
  const themeDispatch = useThemeDispatch();

 const toggleTheme = () => {
   const newThemeAction = theme === 'light' ?
          {
           type: 'SET_DARK', 
           payload: 'dark'
          } : {
           type:'SET_LIGHT', 
           payload: 'light'
          };

   dispatch(newThemeAction);
 };

  return <button onClick={toggleTheme}>Toggle theme</button>;
}

—————————————————————–

As I have said, it is fully typesafe.

Hence, 99% chance of getting into unexpected bugs. Verify it yourself!

Provides Typesafe API for state management

In the above screenshot, we are seeing that it is giving auto-suggestions and throwing red squiggles as the type for the argument themeDispatch

is not satisfied. Thus, it enhances DX to a great extent.

To conclude, in part one, we solved the global state management with context API and introduced a new pattern of multiple stores.

This part was all about client state management. But what about the server state management, data that are relied on the server?

Hence, stay tuned 🙊 to ditch Redux for server state just like we did in this blog with the client state.