Master React Global State with a Custom Hook: Simplify Your App’s State Management
frank edekobi
Posted on September 15, 2024
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
}
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 = {}
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>© 2024 My Website</p>
</footer>
</div>
);
}
export default Layout;
Add the useStateHook before the return statement
// Assuming the IComponentState and initComponentState are defined
const global = useStateHook<IComponentState>(initComponentState);
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;
};
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>© 2024 My Website</p>
</footer>
</div>
</GlobalContext.Provider>
);
};
export default Layout;
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: ""
}
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;
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
}
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
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": ""
}
}
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);
}
You can just do this:
console.log(user.name);
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>
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);
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
Posted on September 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.