Just imagine a file, where all of your state, all the possible actions upon it, and the utilities to retrieve the state resides.
In React terminology, just imagine a React Hook which returns the live state, and all the actions alongside it, which can update the state and can be used anywhere in the application.
Sounds intriguing, right ?
Let’s head straight into the action! Oh and btw, I am a sucker for TypeScript, so the below coding will be in TypeScript.
Let’s try and create a context module for our count feature wherein we should be able to increment, decrement, reset and set our counter to a specific value.
And also, a special note, this file is going to be a .tsx
file as we are going to export our CountContextProvider
as well.
Types
One of the most tedious jobs while using TypeScript is preparing or defining all the types that will be needed in the code. Although, once the setup is done, coding feels a whole lot safer and clean.
Let’s start with defining what our count state will look like!
type CountStateType = {
count: number;
};
One more thing to note here - I personally prefer to use type
instead of interface
. I have my reasons.
Now, it’s time to define how our actions will look like!
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" }
| { type: "set"; payload: number };
Next, we need a way to dispatch these actions, right ?
A dispatch is nothing but a function carrying an action that needs to be executed on state.
type DispatchType = (action: Action) => void;
Now we have a state and a dispatch! Everything we need to define our context!
type ContextType = {
state: CountStateType;
dispatch: DispatchType;
};
Any more types left ? Hmm, let me see!
Oh wait! We need one more type to define the props for our context provider.
type CountContextProviderProps = {
children: React.ReactNode;
};
Context
Now comes the part where we create a context for our feature.
const CountContext = createContext<ContextType | undefined>(undefined);
You must be thinking why did I put that extra undefined
in there ? Well, context can be undefined if useContext
hook, which can retrieve the tools of our feature, is used outside CountContextProvider
which is our context provider.
Make sure to have all necessary imports ready in your code! Very important!
Reducer
We will be using useReducer
hook later in the code, which requires us to create a reducer for our count feature which computes the new state on the basis of existing state and the action that is performed on it.
function countReducer(state: CountStateType, action: Action): CountStateType {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return { count: 0 };
case "set":
return { count: action.payload };
}
}
Provider
It’s finally time to create the Provider for our count feature.
We are going to use useReducer
hook here as I had promised earlier, and we will need an initial state which we will pass to the useReducer
hook.
A Provider also needs a value which will be available to all the consumers via the useContext
hook.
Here’s what we are going to do - wrap the state and dispatch that we will get from useReducer
into an object! Wild, right ?
So, this is what our Provider will look like -
const initialState: CountStateType = {
count: 0,
};
export default function CountProvider({ children }: CountContextProviderProps) {
const [state, dispatch] = useReducer(countReducer, initialState);
return (
<CountContext.Provider value={{ state, dispatch }}>
{children}
</CountContext.Provider>
);
}
The Ultimate Hook
This is one of the major differentiating factors between my Context Module pattern and Kent’s Context Module pattern. I have provisioned various action functions which can be retrieved from the same hook which also delivers the live state to the consumers.
export function useCount() {
const context = useContext(CountContext);
if (context === undefined)
throw new Error("Please use the useCount hook inside CountProvider");
const { state, dispatch } = context;
function incrementCounter() {
dispatch({ type: "increment" });
}
function decrementCounter() {
dispatch({ type: "decrement" });
}
function resetCounter() {
dispatch({ type: "reset" });
}
function setCounter(newValue: number) {
dispatch({ type: "set", payload: newValue });
}
return {
state,
incrementCounter,
decrementCounter,
setCounter,
resetCounter,
};
}
Notice the use of undefined
here which we talked about earlier in the blog. This can be highly useful as many developers usually do forget that they need to wrap the component tree with a Provider first.
Usage
In order to use the state or all it’s actions in a component, all we need to do -
const { state, incrementCounter } = useCount();
.
.
.
return (
<div>
<div>{state.count}</div>
<button onClick={incrementCounter}>Increment</button>
</div>
)
Doesn’t it make more sense this way ? And this can be used anywhere in the application as long as it is inside the Provider.
All Stitched Together
import { createContext, useContext, useReducer } from "react";
type CountStateType = {
count: number;
};
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" }
| { type: "set"; payload: number };
type DispatchType = (action: Action) => void;
type ContextType = {
state: CountStateType;
dispatch: DispatchType;
};
type CountContextProviderProps = {
children: React.ReactNode;
};
const CountContext = createContext<ContextType | undefined>(undefined);
const initialState: CountStateType = {
count: 0,
};
function countReducer(state: CountStateType, action: Action): CountStateType {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return { count: 0 };
case "set":
return { count: action.payload };
}
}
export default function CountProvider({ children }: CountContextProviderProps) {
const [state, dispatch] = useReducer(countReducer, initialState);
return (
<CountContext.Provider value={{ state, dispatch }}>
{children}
</CountContext.Provider>
);
}
export function useCount() {
const context = useContext(CountContext);
if (context === undefined)
throw new Error("Please use the useCount hook inside CountProvider");
const { state, dispatch } = context;
function incrementCounter() {
dispatch({ type: "increment" });
}
function decrementCounter() {
dispatch({ type: "decrement" });
}
function resetCounter() {
dispatch({ type: "reset" });
}
function setCounter(newValue: number) {
dispatch({ type: "set", payload: newValue });
}
return {
state,
incrementCounter,
decrementCounter,
setCounter,
resetCounter,
};
}
At the end, all I want to say is that this pattern is one of the most clean, concise and readable state management patterns out there, and I’ve been in this game for 7 years now!
Do let me know if you liked it! Thanks!