Ever since the introduction of hooks, using React's Context API has become really simple. If you were relatively new to react when hooks came out (like myself), and were curious what this meant for Redux, you must have come across multiple articles on the internet that more than justified why Redux was still better for medium to large sized applications.
But what if you're working on a relatively smaller application that does not need all the bells-and-whistles that you get with Redux? Could you create your own Redux-like state management solution with just Context API and Hooks? Sure.
I recently came across one such project that was the perfect opportunity to test this out. It was originally written in React 16.3, used the concept of controller and controlled components for state management, and made occasional use of Context API to avoid prop-drilling. The ask was to move it to the latest version of React and re-write the components to use hooks instead. Oh, and it had to use TypeScript!
The goal of this article is to showcase the TypeScript-friendly code structure we used in the project, so that anyone in our situation could build on top of this, instead of re-inventing the wheel. Now I know, there have been multiple articles like this but I had to show our take on it!
I'll be using a really simple example to demonstrate the project structure, that does the following:
- Takes in a Player's Name and Age
- Adds his details to a list.
- Click on the list item to delete the entry
The form for taking in the player's details and the list will be two different components that will communicate using Context API. This is a really trivial example, but should be enough to explain this approach.
This is going to be a relatively lengthy read, so, in case you want to figure it out yourself, here's the code sandbox link to the final solution:
Let's begin!
Assuming you have a basic react + typescript environment set up, create the following folders inside src directory:
- models - Place data model here. In our case, the type-definition of Player.
- reducers - Place your reducers and action generators inside this folder
- contexts - Place your context providers in here.
- components - Place your components here
Then, create our Player type-definition like so (see the comment on top of the code snippets for the exact path):
// scr/model/Player.ts
export type Player = {
name: string;
age: number;
};
We'll be keeping our data model as simple as possible. In our example, two fields should be enough.
Once our data model is defined, let's move on to actions. Here, we define what actions we allow our application to make on our data:
// src/reducers/player/player-actions.ts
import { Player } from "../model/Player";
// Type of Actions allowed
export enum PlayerActionTypes {
ADD_PLAYER = "ADD_PLAYER",
REMOVE_PLAYER = "REMOVE_PLAYER"
}
// The data type of the action object.
// Usually, we only send the whole data in Add, and only a unique identifier in other actions
// But we'll ignore that in this example
export type PlayerAction = {
type: PlayerActionTypes;
payload?: Player;
};
// Action Generator for ADD
export const addPlayer = (player: Player) => {
return {
type: PlayerActionTypes.ADD_PLAYER,
payload: player
};
};
// Action Generator for Remove
export const removePlayer = (player: Player) => {
return {
type: PlayerActionTypes.REMOVE_PLAYER,
payload: player
};
};
We'll allow our application to either ADD or REMOVE a user. Comments in the code snippet explain what each line does.
In case you are not aware of Actions or Action Creators, please check out this link.
Once our Actions are created, we need to define a Reducer. A reducer must be a pure function that takes in the current state and an action, and returns a new state. Here is how we define our reducer:
// src/reducers/player/player-reducer.ts
import { Player } from "../../model/Player";
import { PlayerAction, PlayerActionTypes } from "./player-actions";
export const playerReducer = (state: Player[], action: PlayerAction) => {
switch (action.type) {
case PlayerActionTypes.ADD_PLAYER:
return state.concat(action.payload);
case PlayerActionTypes.REMOVE_PLAYER:
return state.filter((player: Player) => player.name !== action.payload.name)
default:
return state;
}
};
As you can see in the above snippet, the Reducer is simply a switch case on the action types. Always ensure that you do not use methods that directly mutate the state.
Now that we have our Actions and Reducer ready, it's time we start creating our context and context providers.
I'll break down Player context module into smaller chunks. We'll start with creating the context first:
export const defaultPlayerListState: Player[] = [];
export type PlayerListContext = {
playerList: Player[];
playerDispatch: React.Dispatch<PlayerAction>;
};
export const playerListContext = React.createContext<
PlayerListContext | undefined
>(undefined);
This is what the context would normally look like, but there's an excellent article by Kent C. Dodds that explains why splitting up the state and dispatch context is better for performance. Check it out by clicking here.
So based on this newfound knowledge, let's change our context to look like this:
export const playerListState = React.createContext<Player[] | undefined>(
undefined
);
export const playerListDispatch = React.createContext<
React.Dispatch<PlayerAction> | undefined
>(undefined);
Since we've split our state and dispatch into two separate contexts, let's create our custom context provider that will set up its children with both:
export const PlayerListProvider = ({
children
}: {
children: React.ReactNode;
}) => {
const [state, dispatch] = useReducer(playerReducer, []);
return (
<playerListState.Provider value={state}>
<playerListDispatch.Provider value={dispatch}>
{children}
</playerListDispatch.Provider>
</playerListState.Provider>
);
};
Then, let's create a custom hook to let our consumer use our context:
export const usePlayerListState = (): Player[] => {
const context = React.useContext(playerListState);
if (undefined === context) {
throw new Error("Please use within PlayerListStateProvider");
}
return context;
};
export const usePlayerListDispatch = (): React.Dispatch<PlayerAction> => {
const context = React.useContext(playerListDispatch);
if (undefined === context) {
throw new Error("Please use within PlayerListDispatchProvider");
}
return context;
};
We could directly use React.useContext(playerListDispatch);
in our components, but having a custom hook lets us add additional functionality like error handling in this scenario when you try to use this context in a component that is not within its provider. This is also something I picked up from Kent C. Dodds. Here's a link to his article.
This is what our complete context module must look like:
// src/context/Player.tsx
import React, { useReducer } from "react";
import { Player } from "../model/Player";
import { PlayerAction } from "../reducers/player/player-actions";
import { playerReducer } from "../reducers/player/player-reducer";
export const playerListState = React.createContext<Player[] | undefined>(
undefined
);
export const playerListDispatch = React.createContext<
React.Dispatch<PlayerAction> | undefined
>(undefined);
export const PlayerListProvider = ({
children
}: {
children: React.ReactNode;
}) => {
const [state, dispatch] = useReducer(playerReducer, []);
return (
<playerListState.Provider value={state}>
<playerListDispatch.Provider value={dispatch}>
{children}
</playerListDispatch.Provider>
</playerListState.Provider>
);
};
export const usePlayerListState = (): Player[] => {
const context = React.useContext(playerListState);
if (undefined === context) {
throw new Error("Please use within PlayerListStateProvider");
}
return context;
};
export const usePlayerListDispatch = (): React.Dispatch<PlayerAction> => {
const context = React.useContext(playerListDispatch);
if (undefined === context) {
throw new Error("Please use within PlayerListDispatchProvider");
}
return context;
};
We have our data model, our reducers, our actions and our context providers ready. Now let's begin building our components, starting with the form to accept the player details:
// src/components/AddPlayer
import React from "react";
import { usePlayerListDispatch } from "../context/Player";
import { addPlayer } from "../reducers/player/player-actions";
export const AddPlayer = () => {
const playerDispatch = usePlayerListDispatch();
const [playerName, setPlayerName] = React.useState<string>("");
const [playerAge, setPlayerAge] = React.useState<string>("");
const onSubmitHandler = event => {
event.preventDefault();
if (playerName !== "" && playerAge !== "" && !isNaN(Number(playerAge))) {
playerDispatch(
addPlayer({
name: playerName,
age: Number(playerAge)
})
);
setPlayerName("");
setPlayerAge("");
}
};
return (
<form onSubmit={onSubmitHandler}>
<label htmlFor="playerName">Player Name: </label>
<input
type="text"
placeholder="Enter Player Name"
name="playerName"
id="playerName"
value={playerName}
onChange={event => setPlayerName(event.target.value)}
/>
<br />
<label htmlFor="playerAge">Player Age: </label>
<input
type="number"
placeholder="Entery Player Age"
name="playerAge"
id="playerAge"
value={playerAge}
onChange={event => setPlayerAge(event.target.value)}
/>
<br />
<input type="submit" className={`btn btn-primary`} name="submit" />
</form>
);
};
The above component only adds data, never reads it. So we don't need to use usePlayerListState hook. This is where decoupling the state and dispatch is useful.
Then, we build our player list component to display the list of players. We've split it into two components:
// src/components/PlayerList
import React from "react";
import { usePlayerListState } from "../../context/Player";
import { PlayerItem } from "./Player";
export const PlayerList = () => {
const playerList = usePlayerListState();
return (
<>
{playerList.map(player => {
return (
<>
<PlayerItem player={player} />
<br />
</>
);
})}
</>
);
};
// src/components/Player
import React from "react";
import { usePlayerListDispatch } from "../../context/Player";
import { removePlayer } from "../../reducers/player/player-actions";
import { Player } from "../../model/Player";
export const PlayerItem = ({ player }: { player: Player }) => {
const playerDispatch = usePlayerListDispatch();
return (
<span
onClick={() => {
playerDispatch(removePlayer(player));
}}
>
{`Player ${player.name} is ${player.age} years old`}
</span>
);
};
The above examples are fairly simple. The PlayerList components takes a list of players and passes each player's details to the Player component that display individual player information and dispatches a remove action when you click on it.
We have everything ready. It's time to wire things up. Open up App.tsx and place the following lines of code inside:
export default function App() {
return (
<PlayerListProvider>
<AddPlayer />
<PlayerList />
</PlayerListProvider>
);
}
And voila! We're done! Play around with the codesandbox I've linked in the beginning and let me know what you think in the comments section!
Personally, what I like about this approach is that everything Context API related is tucked away inside our custom hooks. Some of my friends I showed this to didn't even realize it was using Context API!
Top comments (6)
This is my first ever article. Please let me know what you think of my content and writing style!
Thoroughly entertaining article. Kudos !!!
Thanks!
Impressive !!!
If we would like keep our code simple ass possible, why need to use context API? In my view that is just over complicate the code vs simple useReducer.
useReducer and Context API are independent concepts. They're used in conjunction here to get redux-like functionality.
The Context API was introduced to avoid "prop drilling" in react. Earlier, it was only possible using redux.
Here's a link explaining what prop drilling is:
kentcdodds.com/blog/prop-drilling/