Abstracting State Management in React with Typescript, and its Benefits

Volodymyr Yepishev - Jun 3 - - Dev Community

Bizarre illustration by Google Gemini AI.

State management is something unavoidable on the UI, be it local component scoped, or global, which in some enterprise applications can grow to gargantuan sizes.

The topic I would like to dwell upon today is tight coupling, which can be created by a state management library, how state management can be abstracted and what benefits and drawbacks it has. Perhaps it should be noted that for the applications that are not maintained in the long run or are short-lived, it does not really matter which state management tool is used or if there is an abstraction at all. Yet, those should be obvious.

As a part of this article we will take a look at Redux in a React application, the coupling it creates, how it can be abstracted and tested and how the state management tool can be swapped once the abstraction has been created, so the public api of the state management remains the same for the app, but the actual provider of it changes.

Firstly let us create a React application and add Redux:

npx create-react-app demo_react-abstracting-state-management --template typescript && install @reduxjs/toolkit react-redux
Enter fullscreen mode Exit fullscreen mode

Following the quick start we can add a counter state, a component to render it, and get something similar to what is found in this commit.

Our counter slice is the following, it is almost identical to the one from the Redux quickstart:

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

import type { RootState } from '../../store';

// Define a type for the slice state
interface CounterState {
    value: number;
}

// Define the initial state using that type
const initialState: CounterState = {
    value: 0,
};

export const counterSlice = createSlice({
    name: 'counter',
    // `createSlice` will infer the state type from the `initialState` argument
    initialState,
    reducers: {
        increment: (state) => {
            state.value += 1;
        },
        decrement: (state) => {
            state.value -= 1;
        },
        // Use the PayloadAction type to declare the contents of `action.payload`
        incrementByAmount: (state, action: PayloadAction<number>) => {
            state.value += action.payload;
        },
    },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
Enter fullscreen mode Exit fullscreen mode

The noticeable difference is that we are placing Redux-related items in store/redux, because we intend to have abstraction in store and store/redux will be one of the implementations. The end goal is to provide an abstract exports from store, in a way that consumers are no longer coupled with a specific state management library.

Apart from a significant amount of boilerplate, which is not critical, what problem does it introduce to the application? The Counter component is now coupled with Redux, and so is our whole application, since it is wrapped in the Redux provider. While this is not a problem if your tech stack is married to Redux, this somewhat is limiting and coupling: changes to Redux in the long run could cause introducing changes to Counter component or other consumers, since all components utilizing imports from Redux will be coupled with it.

See the Counter component:

import { decrement, increment, useAppDispatch, useAppSelector } from '../../store/redux';

export const Counter = () => {
    const count = useAppSelector((state) => state.counter.value);
    const dispatch = useAppDispatch();

    return (
        <div>
            <div>
                <button
                    aria-label="Increment value"
                    onClick={() => dispatch(increment())}
                >
                    Increment
                </button>
                <span>{count}</span>
                <button
                    aria-label="Decrement value"
                    onClick={() => dispatch(decrement())}
                >
                    Decrement
                </button>
            </div>
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

Were we to migrate to some other state management tool, the scope of change would be large as well, since Redux would have roots all over the place. You could consider it a soft form of vendor lock, when you could migrate, but the effort is not worth it, it is beneficial for libraries to impose such bounds, but not necessarily beneficial for your application, especially if a better alternative appears on the horizon.

Looking at the exposed state, we can deduct some abstractions: a model for state, a model for hook to access state and methods to modify it and a model for the state provider, which is a wrapper for our application.

Observe the following commit.

So the state and managing methods can be expressed with the following interface:

// src/store/models/counter.state.ts
export interface CounterState {
    count: number;
    increment: () => void;
    decrement: () => void;
}
Enter fullscreen mode Exit fullscreen mode

The hook delivering them would have the following functional interface:

// src/store/models/counter-state-hook.model.ts
import { CounterState } from './counter.state';

export interface CounterStateHook {
    (): CounterState;
}
Enter fullscreen mode Exit fullscreen mode

And, finally, the provider:

// src/store/models/state-provider.model.ts
import { ReactElement, ReactNode } from 'react';

export interface StateProvider {
    (args: { children?: ReactNode }): ReactElement;
}
Enter fullscreen mode Exit fullscreen mode

Evidently, the Redux items, currently present in our application hardly conform to these abstractions, and we would need to create an adapter to make things compliant, so let us create one, which will encapsulate Redux state management:

// src/store/redux/features/counter/counter-hook.ts
import { useCallback } from 'react';

import { useAppDispatch, useAppSelector } from '../../hooks';
import { decrement, increment } from './counter-slice';
import { CounterStateHook } from '../../../models';

export const useCounterHook: CounterStateHook = () => {
    const count = useAppSelector((state) => state.counter.value);

    const dispatch = useAppDispatch();

    const inc = useCallback(() => {
        dispatch(increment());
    }, [dispatch]);

    const dec = useCallback(() => {
        dispatch(decrement());
    }, [dispatch]);

    return {
        count,
        decrement: dec,
        increment: inc,
    };
};
Enter fullscreen mode Exit fullscreen mode

Also, the Redux provider now needs to be adapted to conform to the provider interface:

// src/store/redux/StateProvider.tsx
import { Provider } from 'react-redux';

import { store } from './store';
import { StateProvider } from '../models';

export const StateContextProvider: StateProvider = ({ children }) => {
    return <Provider store={store}>{children}</Provider>;
};
Enter fullscreen mode Exit fullscreen mode

With these in place, we can now provide abstract exports from store which conceal the actual implementation of the underlying state management library.

Here goes our new state management provider:

// src/store/state.provider.ts
import { StateProvider as Provider } from './models';
import { StateContextProvider as ReduxStateContextProvider } from './redux';

export const StateProvider: Provider = ReduxStateContextProvider;
Enter fullscreen mode Exit fullscreen mode

And the hook:

// src/store/counter.hook.ts
import { CounterStateHook } from './models';
import { useCounterHook as ReduxUseCounterHook } from './redux';

export const useCounter: CounterStateHook = ReduxUseCounterHook;
Enter fullscreen mode Exit fullscreen mode

Which in turn means we can use them in Counter:

import { useCounter } from '../../store';

export const Counter = () => {
    const { increment, decrement, count } = useCounter();

    return (
        <div>
            <div>
                <button aria-label="Increment value" onClick={increment}>
                    Increment
                </button>
                <span>{count}</span>
                <button aria-label="Decrement value" onClick={decrement}>
                    Decrement
                </button>
            </div>
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

The Counter no longer has any idea which library manages the state, or even if it s a library at all, which is good.

Now, provider for the app, and a small cleanup for imports:

// src/index.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import reportWebVitals from './reportWebVitals';

import { App } from './App';

import { StateProvider } from './store';

const root = createRoot(document.getElementById('root') as HTMLElement);
root.render(
    <StrictMode>
        <StateProvider>
            <App />
        </StateProvider>
    </StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Enter fullscreen mode Exit fullscreen mode

Some state provider, no details, also good.

Now, before we proceed to migrate to a different state management tool, what we could do is write tests covering the public api of our store module, namely the useCounter hook, which we can do using React Testing Library:

import { act } from 'react';
import { renderHook } from '@testing-library/react';

import { useCounter } from './counter.hook';
import { StateProvider } from './state.provider';

describe('Tests for the counter state', () => {
    test('should increment counter', () => {
        const { result } = renderHook(useCounter, { wrapper: StateProvider });

        act(() => {
            result.current.increment();
        });

        expect(result.current.count).toBe(1);
    });

    test('should decrement counter', () => {
        const { result } = renderHook(useCounter, { wrapper: StateProvider });
        const current = result.current.count;

        act(() => {
            result.current.decrement();
        });

        expect(result.current.count).toBe(current - 1);
    });
});
Enter fullscreen mode Exit fullscreen mode

Once we swap the implementation, the test will show us if everything is working, and we do not need to re-visit consumers.

As a poof of concept for state management tool migration, I chose Zustand, because all the kool kids write articles about it and I had never used it before. Apparently it is pronounced /ˈʦuːʃtant/ and the name itself is taken from German, but don't take my word for it.

npm i zustand
Enter fullscreen mode Exit fullscreen mode

So we create a folder zustand next to the redux, it is going to be another implementation of our abstraction. It has no providers, so we would have to implement and empty one to comply with our abstraction (observe the commit):

// src/store/zustand/StateProvider.tsx
import { StateProvider } from '../models';

export const StateContextProvider: StateProvider = ({ children }) => {
    return <>{children}</>;
};
Enter fullscreen mode Exit fullscreen mode

However, its store creating function actually accepts and interface, and we can pass the existing one we created earlier, CounterState, which means no extra adapters, it will just work (which is amazingly convenient):

// src/store/zustand/counter.hook.ts
import { create } from 'zustand';
import { CounterState } from '../models';

export const useCounter = create<CounterState>((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Enter fullscreen mode Exit fullscreen mode

All what is left is to swap implementations for the hook:

// src/store/counter.hook.ts
import { CounterStateHook } from './models';
import { useCounterHook as ReduxUseCounterHook } from './redux';
import { useCounter as ZustandUseCounterHook } from './zustand';

export const useCounter: CounterStateHook = ZustandUseCounterHook; // || ReduxUseCounterHook;
Enter fullscreen mode Exit fullscreen mode

And the state provider:

// src/store/state.provider.ts
import { StateProvider as Provider } from './models';
import { StateContextProvider as ReduxStateContextProvider } from './redux';
import { StateContextProvider as ZustandStateProvider } from './zustand';

export const StateProvider: Provider = ZustandStateProvider; // || ReduxStateContextProvider;
Enter fullscreen mode Exit fullscreen mode

We can run the tests now and make sure switching the underlying library, which does the heavy-lifting for state management had no impact on the store module's public api, which means the consuming components need no changes.

So to sum it up, what are the benefits? Looser coupling for components and state management, more flexibility, easier migration when changing the state management library. What are the drawbacks? Some abstraction overhead and need to write some interfaces to define public api and potentially adapters to make state management library comply.

That's it, have fun, the repo is here :)

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .