DEV Community

Cover image for Master React Global State with a Custom Hook: Simplify Your App’s State Management
frank edekobi
frank edekobi

Posted on

Master React Global State with a Custom Hook: Simplify Your App’s State Management

Question: What is this tutorial about?

This tutorial introduces a practical, custom approach that makes it easier to organize and manage state for small to large or complex React applications using useReducer + React Context.

Why go through all this stress?

This approach helps you manage shared state more easily in larger React apps plus:

  • No Need for Conditional Chaining (?.)
  • Cleaner Code
  • Centralized State Management
  • Easier to Reset or Update State
  • Scalable and Flexible
  • Consistency Across the App

Who is this tutorial for?

This tutorial is aimed at developers with an intermediate skill level in React and Typescript, who are familiar with basic state management and hooks but want to deepen their knowledge by learning how to implement a custom global state solution using hooks like useReducer and React Context.

So if you think it's worth the stress, LET’S DIVE IN!!!

React Context + useReducer hook

Let's start with the useReducer

Imagine you have a bunch of data (a “state”) that changes as the user interacts with your app (like filling out a form or choosing different options). You want an organized way to update this data, clear it out, or reset everything back to its starting point.

Let’s break it down

  • useReducer: is like a traffic control system for your state. It decides how the state should change based on certain actions you send it (like "Update this part" or "Clear everything").
  • State: The “state” is a collection of values that describe the current situation in your app (like a user’s inputs or choices).
  • Action: An “action” is a signal you send to tell the reducer what to do with the state. For example, “Update the user’s name” or “Clear all data.”

Now you’ve gotten the idea, let’s get into the tutorial proper

Tutorial

First, Create a state-hook.tsx file.

export const reducer = <T extends { [key: string]: any }>(
  initState: T, // The initial state when you start the app
  state: T,     // The current state, as it changes over time
  action: { type: keyof T | 'CLEAR_ALL'; payload?: any } // What should change
) => {
  switch (action.type) {
    case 'CLEAR_ALL':
      return { ...initState } // Reset everything to the original state

    default:
      if (action.type in state) {
        // Only update if the new value is different
        if (state[action.type] === action.payload) return state 
        return { ...state, [action.type]: action.payload } // Update part of the state
      }
      return state // Return the same state if nothing changes
  }
}

export interface IUSH<T> {
  state: T
  updateState: <A extends keyof T>(type: A, payload: T[A]) => void // Ensure payload matches the type of the state property
  clearState: <A extends keyof T>(type: A) => void
  clearAll: () => void
}

export const useStateHook = <T extends { [key: string]: any }>(
  initState: T // Starting values for the state
): IUSH<T> => {
  const [state, dispatch] = useReducer(reducer, initState) // This sets up the state system

  // Function to update part of the state
  const updateState = useCallback(
    <A extends keyof T>(type: A, payload: T[A]) => {
      dispatch({ type, payload }) // Send an action to the reducer to update state
    },
    []
  )

  // Function to reset a specific part of the state
  const clearState = useCallback(<A extends keyof T>(type: A) => {
    dispatch({ type, payload: initState[type] }) // Reset to the initial value for that part
  }, [])

  // Function to clear everything (reset all state)
  const clearAll = useCallback(() => {
    dispatch({ type: 'CLEAR_ALL' }) // Clear the entire state
  }, [])

  return { state, updateState, clearState, clearAll } // Return the state and the functions
}
Enter fullscreen mode Exit fullscreen mode

The reducer function is the brain behind the state updates. It checks what action has been sent and changes the state accordingly.

The useStateHook function is a custom hook you would use in your app. It helps you manage the state with three main functions: updateState, clearState, clearAll

Next, create a component-state.ts file

Define the interface IComponentState and the object initComponentState

export interface IComponentState {
// define your state here
}

export const initComponentState: IComponentState = {}
Enter fullscreen mode Exit fullscreen mode

Next, add the useStateHook to your Layout file

If you do not have a layout file add it to the file that defines the general structure of your web application.

Example of a layout file structure

import React from 'react';
import { Outlet, Link } from 'react-router-dom'; // Used with react-router for navigation

const Layout = ({ children }) => {
  return (
    <div>
      {/* Header */}
      <header>
        <nav>
          <ul>
            <li><Link to="/">Home</Link></li>
            <li><Link to="/about">About</Link></li>
            <li><Link to="/contact">Contact</Link></li>
          </ul>
        </nav>
      </header>

      {/* Main content (this changes based on the page) */}
      <main>
        {children} {/* This renders the nested content */}
        or 
        <Outlet /> {/* This renders the specific page/component content */}
      </main>

      {/* Footer */}
      <footer>
        <p>&copy; 2024 My Website</p>
      </footer>
    </div>
  );
}

export default Layout;
Enter fullscreen mode Exit fullscreen mode

Add the useStateHook before the return statement

// Assuming the IComponentState and initComponentState are defined
const global = useStateHook<IComponentState>(initComponentState);
Enter fullscreen mode Exit fullscreen mode

Next, create a context.ts file

import React, { createContext, useContext } from 'react';

// Define the type for your context
interface IGlobalContext {
  global: IUSH<IComponentState>
}

// Create the context with an initial value
export const GlobalContext = createContext<IGlobalContext>({
  global: {
    state: initComponentState,
    updateState: () => {},
    clearState: () => {},
    clearAll: () => {}
  }
});

// Custom hook to use the GlobalContext
export const useGlobalContext = () => {
  const context = useContext(GlobalContext);
  if (!context) {
    throw new Error('useGlobalContext must be used within a GlobalProvider');
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

Next, Modify the layout file

Now, in the layout file, you can wrap the children with the GlobalContext.Provider so that the state is available throughout the app:

import React from 'react';
import { useStateHook } from './useStateHook'; // Assuming this is your custom hook
import { GlobalContext } from './GlobalContext'; // Context created earlier

const initComponentState = {
  // define the initial state here
};

const Layout: React.FC = ({ children }) => {
  const global = useStateHook<IComponentState>(initComponentState); // Initialize global state

  return (
    <GlobalContext.Provider value={{global}}>
      <div>
        {/* Header */}
        <header>
          <nav>
            <ul>
              <li><a href="/">Home</a></li>
              <li><a href="/about">About</a></li>
            </ul>
          </nav>
        </header>

        {/* Main content */}
        <main>{children}</main>

        {/* Footer */}
        <footer>
          <p>&copy; 2024 My Website</p>
        </footer>
      </div>
    </GlobalContext.Provider>
  );
};

export default Layout;
Enter fullscreen mode Exit fullscreen mode

Congratulations!!!, we’re done with the setup.

Now let’s test it to see if it works as advertised

First, modify the component-state.ts file

We’ll add some states:

export interface IComponentState {
// define your state here
    name: string
    occupation: string
}

export const initComponentState: IComponentState = {
   name: "",
   occupation: ""
}
Enter fullscreen mode Exit fullscreen mode

Next, Create a Child Component.tsx and Access the Global State in it

Now, any child component wrapped inside the Layout can use the useGlobalContext hook to access and manipulate the global state.

Here’s how a child component can use the global state:

import React from 'react';
import { useGlobalContext } from './GlobalContext';

const ChildComponent: React.FC = () => {
  const { global } = useGlobalContext();
  const { state, updateState, clearState, clearAll } = global;

  return (
    <div>
      <h1>Global State Example</h1>
      <pre>{JSON.stringify(state)}</pre>

      {/* Example: Update a part of the global state */}
      <button
        onClick={() => updateState('name', 'Franklyn Edekobi')}
      >
        Update Name
      </button>

      <button
        onClick={() => updateState('occupation', 'Software Engineer')}
      >
        Update Occupation
      </button>

      {/* Example: Clear a part of the global state */}
      <button onClick={() => clearState('name')}>Clear State</button>

      {/* Example: Clear all global state */}
      <button onClick={clearAll}>Clear All</button>
    </div>
  );
};

export default ChildComponent;
Enter fullscreen mode Exit fullscreen mode

Summary of Steps:

  • useStateHook: This manages the global state.
  • Context (GlobalContext): Created to provide the global state to all components.
  • Layout: Wraps children with GlobalContext.Provider to make the state accessible globally.
  • Child components: Use useGlobalContext to access and update the global state.

By structuring it this way, you have a clean and scalable way to manage global state in your React app!

Benefits

This setup with initComponentState and React Context has several benefits, especially when managing global state across an app. I'll break it down step by step, explaining it in a way that's easy to understand.

What is initComponentState?
initComponentState is the initial state of your global state. It’s like having a starting point or default values for everything your app needs. By having these defaults, you don’t need to worry about checking if something is undefined before using it, which can save you from errors.

Example of the Problem It Solves
Let’s say your app has a part of the global state user that stores the user’s name. Without the initial state, the global state might look like this when no one is logged in:

{
  "user": undefined
}
Enter fullscreen mode Exit fullscreen mode

This means you’d have to write code that checks if user exists before you can use it, like this:

console.log(user?.name); // If 'user' is undefined, this won't crash
Enter fullscreen mode Exit fullscreen mode

But with initComponentState, you make sure that user always exists, even if it's empty. So your initial state might look like this:

{
  "user": {
    "name": ""
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, you can confidently access user.name without worrying about checking if user exists first.

Benefits of This Setup

No Need for Conditional Chaining (?.)

Because initComponentState ensures that every part of your global state has an initial value, you don't have to use tricky checks like user?.name to avoid errors. The initial state gives you confidence that user (or any other part of the state) will always be defined, so you can access it directly without worrying about things breaking.

Cleaner Code

By using this setup, you write cleaner and more readable code. You don’t need to sprinkle your code with checks to see if things are undefined or null. This makes your code easier to maintain and understand.

Instead of writing this:

if (user?.name) {
  console.log(user.name);
}
Enter fullscreen mode Exit fullscreen mode

You can just do this:

console.log(user.name);
Enter fullscreen mode Exit fullscreen mode

It’s simpler and more straightforward!

Centralized State Management

This setup makes managing the state across your app easy and centralized. Since the state is shared through the React Context (using the GlobalContext), you don’t need to pass down the state manually through every component. Instead, any component wrapped in the Layout can access the global state using a simple hook (useGlobalContext).

For example, without global state, you’d have to pass data from parent to child components like this:

<ParentComponent>
  <ChildComponent user={user} />
</ParentComponent>
Enter fullscreen mode Exit fullscreen mode

With this setup, you don’t need to pass user as a prop anymore. The child can just use the global state:

const { state } = useGlobalContext();
console.log(state.user.name);
Enter fullscreen mode Exit fullscreen mode

This reduces the complexity and effort of passing state manually.

Easier to Reset or Update State:

With the useStateHook setup, you can easily reset the state or update specific parts without affecting other parts. For example:

You can clear a specific part of the state (like resetting user).
You can clear the entire state back to its initial values (using clearAll).
This kind of control makes it easy to handle scenarios like logging out a user (clearing user data) or resetting the entire form without losing other data.

Scalable and Flexible

This setup is scalable, meaning it can grow with your app. Whether your app is small or large, this method can handle more complex state structures as your app evolves. You don’t need to rewrite everything when your state becomes bigger or more complicated.

For example, if you add more features (like managing settings or preferences), you can easily expand the global state without much hassle. The flexibility of this approach means you can keep adding to the state without breaking existing functionality.

Consistency Across the App

By having a well-defined initComponentState, you maintain consistent state structure across your app. Every component that uses the global state will have access to the same structure, which reduces bugs and makes debugging easier.

For example, every component knows exactly what the state looks like, so there’s no confusion about what data is available or what format it’s in.

In Summary

No Conditional Chaining:
You avoid ?. checks because every part of the state has a default value.

Cleaner Code:
Your code becomes simpler and easier to read.

Centralized State:
All components have easy access to global state without needing to pass it manually.

Flexible and Scalable:
The setup can grow with your app as it becomes more complex.

Consistency:
Ensures that your state is predictable and easy to manage across the whole app.

This setup simplifies how you manage state in React, making your app more reliable and easier to work on.

I hope you find this useful. Do have a wonderful day

Top comments (0)