Streamlining State Management in React with a Plugin-based Architecture

Streamlining State Management in React with a Plugin-based Architecture

Introduction

State management is an essential part of building complex React applications. As applications grow in size and complexity, managing state can become a daunting task. While the built-in useState hook in React is useful for simple cases, it can quickly become difficult to manage as the number of components and state variables increases.


This is where state management libraries like Redux come in. Redux has become a popular solution for managing state in React applications. It provides a centralized store to manage application state, along with a set of rules and conventions for updating the state in a predictable way.

However, while Redux is a powerful solution, it can also be complex and difficult to use, especially for small to medium-sized applications. It requires a lot of boilerplate code, and can be overkill for simpler use cases.

Fortunately, there are simpler state management solutions available in React. The Context and useReducer hooks, which are built into React, can be used together to create a simple and efficient state management system. This approach has the advantage of being lightweight, easy to set up, and can scale with the size of the application.

In addition, the plugin-based architecture of this state management system allows developers to add and remove functionality as needed, making it more flexible and customizable than larger libraries like Redux. By using a plugin-based architecture, developers can keep their state management system simple and easy to maintain, while still adding new functionality when needed.

In this article, we will explore the Context and useReducer hooks, along with a plugin-based architecture, as a simple and efficient way to manage state in React applications. We will show examples of using this approach, and discuss its advantages over other state management solutions.

Redux and its drawbacks

Redux is a popular state management solution for React applications. It provides a centralized store to manage application state, along with a set of rules and conventions for updating the state in a predictable way. While Redux is a powerful solution, it can also be complex and difficult to use, especially for small to medium-sized applications.


One of the major drawbacks of using Redux is the amount of boilerplate code required. The setup process for Redux can be quite complex, involving multiple files and configuration settings. This can make it difficult for new developers to understand and use effectively.

In addition, Redux can become quite complex as the size of the application grows. The amount of code required to maintain the state and actions can become overwhelming, and can make the codebase difficult to navigate and debug.

Another potential issue with Redux is its impact on application performance. Redux works by creating a centralized store for application state, which can be accessed and updated from anywhere in the application. However, as the size of the application grows, accessing the store can become slower, and can cause performance issues.

Fortunately, there are other, simpler state management solutions available in React. Context and useReducer, for example, provide a simple and efficient way to manage state without the need for a third-party library. By using these built-in hooks, developers can create a lightweight and scalable state management system that is easy to maintain and understand.

Store.tsx

Let start with our store.tsx file which contains our initial stores and context.


  1. Import necessary modules:


import React, { createContext, useReducer } from 'react'
import { Action, IPlugin, StoreContextType } from './types'

Here we import createContext, useReducer from the react module and some types from the ./type module.

2. Define the initial state:

export const initialState = {
  counter: 0,
  loggedIn: false,
  username: '',
}

This defines the initial state for our application. It includes counter, loggedIn, and username as properties with their initial values.

3. Define the root reducer:

function rootReducer(state: typeof initialState, action: Action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, counter: state.counter + 1 }
    case 'DECREMENT':
      return { ...state, counter: state.counter - 1 }
    default:
      return state
  }
}

This is a reducer function that takes the current state and an action as parameters and returns the new state based on the action type. In this example, we have two actions INCREMENT and DECREMENT that update the counter property.

4. Define the StoreContext:

export const StoreContext = createContext<StoreContextType>({
  state: initialState,
  dispatch: () => null,
})

This creates a context object that holds the state and dispatch functions. We use the createContext function and pass it the initial value of state and a dispatch function that returns null.

5. Define the StoreProvider component:

export function StoreProvider({
  children,
  initialState = {
    counter: 0,
    loggedIn: false,
    username: '',
  },
  plugins = [],
}: {
  children: React.ReactNode
  initialState?: TState
  plugins?: IPlugin[]
}) {
  const pluginReducers = plugins.map((p) => p.reducer)
  const pluginStates = plugins.map((p) => p.initialState || {})
  const reducers = [rootReducer, ...pluginReducers]
  const combinedReducer = (state: TState, action: Action) =>
    reducers.reduce((s, r) => r(s, action), state)

  const [state, dispatch] = useReducer(combinedReducer, {
    ...initialState,
    ...Object.assign({}, ...pluginStates),
  })

  return <StoreContext.Provider value={{ state, dispatch }}>{children}</StoreContext.Provider>
}

This is the main component that provides the state and dispatch functions to child components using the StoreContext. It takes three optional props: children, initialState, and plugins. It uses useReducer to create a combinedReducer by combining the rootReducer and the pluginReducers. The initialState and the pluginStates are merged and passed as the initial state to useReducer. The state and dispatch functions are then passed to the StoreContext.Provider as a value.

6. Export the necessary types:

export type TState = typeof initialState

export const StoreContext = createContext<StoreContextType>({
  state: initialState,
  dispatch: () => null,
})

Here we export the TState type which is defined as the type of the initialState. We also export the StoreContext which is defined as a createContext object with the state and dispatch properties.

types.ts

If you are familiar with TypeScript so you should know we are just defining our Types and exporting them here nothing fancy, right?


import type { TState } from './store'

export type Action =
 | { type: 'INCREMENT' }
 | { type: 'DECREMENT' }
 | { type: 'ADD_TO_CART'; payload: any }
 | { type: 'REMOVE_FROM_CART'; payload: any }
 | { type: 'CLEAR_CART' }

export interface IPlugin {
 reducer: (state: any, action: Action) => any
 initialState?: any
}

export interface StoreContextType {
 state: TState
 dispatch: React.Dispatch<Action>
}

cartPlugin.ts

As you guys know we promise to make our context the way to scale easy and extend so that in larger applications we can use it easily.


import type { IPlugin, Action } from './type'

export const cartPlugin: IPlugin = {
 reducer: function cartReducer(state: any, action: Action) {
  switch (action.type) {
   case 'ADD_TO_CART':
    return { ...state, cart: [...state.cart, action.payload] }
   case 'REMOVE_FROM_CART':
    return {
     ...state,
     cart: state.cart.filter((item: any) => item.id !== action.payload),
    }
   case 'CLEAR_CART':
    return { ...state, cart: [] }
   default:
    return state
  }
 },
 initialState: {
  cart: [],
 },
}

Let me explain a little bit about the above snippet.

I have defined a plugin called cartPlugin. It is an implementation of the IPlugin interface which consists of a reducer function and an optional initialState object.

The cartReducer function accepts two arguments - the state object and an action object. Based on the type of the action, it updates the state and returns the updated state object. In this example, the cartReducer handles actions related to adding an item to the cart, removing an item from the cart, and clearing the entire cart.

The initialState object of cartPlugin specifies the initial state of the cart property as an empty array.

This plugin can be used in conjunction with other plugins and the root reducer to create a combined reducer function. The combined reducer function is used by the StoreProvider component to create a new state object based on the previous state and the dispatched action.

App.tsx

Let's import our StoreProvider and plugins and add it to our highest parent component in order all our pages have access to our context


import React from 'react';
import { StoreProvider, StoreContext, counterReducer } from './store';

const App = () => {
  return (
    <StoreProvider initialState={{ count: 0 }} plugins={[/* any plugins */]}>
      <Counter />
    </StoreProvider>
  );
};

const Counter = () => {
  const { state, dispatch } = React.useContext(StoreContext);

  const increment = () => {
    dispatch({ type: 'INCREMENT' });
  };

  const decrement = () => {
    dispatch({ type: 'DECREMENT' });
  };

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick​={increment}>+</button>
      <button onClick​={decrement}>-</button>
    </div>
  );
};

export default App;

in the above snippet in the sake of keeping this article short, I add a Counter component in the same files. as you can see I use UseContet to access our state and dispatch function to fire events in our reducer and increase the counter or decrease the counter by clicking on buttons

Conclusion

A plugin-based architecture for state management in React applications offers a simple, flexible, and extensible solution to avoid the complexity and performance issues of larger state management libraries like Redux.


By using Context and useReducer together with plugins, developers can easily extend the state and add new functionality to their state management system. Plugins can be combined with the main reducer using array methods to create a single, combined reducer function, and the StoreProvider component can be used to provide the state and dispatch functions to child components using the StoreContext.

Overall, using a plugin-based architecture for state management in React applications can greatly simplify the development process and improve performance, making it a valuable tool for any developer.

Please consider following and clap if you like this article!

Useful Link and References


To view or add a comment, sign in

More articles by Ali Alizada

Others also viewed

Explore content categories