16
React context API state management with typescript
We will use the default npx create-react-app app_name --template typescript --use-npm
for anyone with both npm
and yarn
installed in the system or npx create-react-app app_name
for just npm
to setup our initial project
I'll call my app client
for the start
client |-node_modules |- public |- src | ├── App.css | ├── App.tsx | ├── index.tsx | ├── react-app-env.d.ts | ├── components │ | ├── Header.tsx │ | └── Home.tsx | | | | | └── state | | ├── ActionTypes.tsx | | ├── AppProvider.tsx | | ├── interfaces.tsx | | └── reducers | | ├── themeReducer.tsx | | └── userReducer.tsx
First we'll create a directory in the src
folder named state
for keeping all files related to our global state. For reducer
functions we'll create a folder in the state named reducers.
In the AppProvider we'll import createContext
from react to create a context instance for holding our global state and sharing the state value across all children bellow it.
In the handling of different states it's good if we keep the reducers to handle only a concerning section of the state for easy maintainance. In my state I have two states i.e user
and theme
.
I have defined all types for the AppState already in the interfaces.tsx.
The combined reducer
function takes in a given state and passess it to the appropriate reducer
function. We destructure the state in the combinedReducer
arguments and return the state after any update.
To keep a persistent state in the application we use localstorage to store our data. I have set up an APP_STATE_NAME
variable to ensure consistency and ease of access to the localstorage varible.
We first check if there is an existing state in the localstorage, if there is no state registered we use the default state value after.
For syncing state in the AppProvider we import the useReducer
hook from react
for dispatching events on our state.
We pass the state to the AppContext as value. In addition to ensure we keep the app state in sync we use the useEffect
hook to watch for changes in the state and refresh the state in case of any change.
/**
* AppProvider.tsx
*/
import React, { createContext, Dispatch, useEffect, useReducer } from "react";
import { IState, IThemeAction, StateActions, UserActions } from "./interfaces";
import themeReducer from "./reducers/themeReducer";
import userReducer from "./reducers/userReducer";
const APP_STATE_NAME = "testing";
//Check if state already exist and take the instance or set a default value
//in case there is no state in the localstorage
const initialState: IState = JSON.parse(localStorage.getItem(APP_STATE_NAME)!)
? JSON.parse(localStorage.getItem(APP_STATE_NAME)!)
: {
user: {
username: "",
active: false,
},
theme: {
dark: false,
},
};
const AppContext = createContext<{
state: IState;
dispatch: Dispatch<StateActions>;
}>({ state: initialState, dispatch: () => null });
const combinedReducers = (
{ user, theme }: IState,
action: UserActions | IThemeAction
) => ({
user: userReducer(user, action),
theme: themeReducer(theme, action),
});
const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(combinedReducers, initialState);
// Watches for any changes in the state and keeps the state update in sync
//Refresh state on any action dispatched
useEffect(() => {
//Update the localstorage after detected change
localStorage.setItem(APP_STATE_NAME, JSON.stringify(state));
}, [state]);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
export default AppProvider;
export { AppContext, AppProvider };
Defining my types
/**
* interfaces.tsx
*/
import { LOGIN, LOGOUT, THEME } from "./ActionTypes";
export interface IUser {
username: string;
active: boolean;
}
export interface ITheme {
dark: boolean;
}
export interface IState {
user: IUser;
theme: ITheme;
}
export interface IUserLogin {
type: typeof LOGIN;
payload: IUser;
}
export interface IUserLogout {
type: typeof LOGOUT;
payload: {};
}
export interface IThemeAction {
type: typeof THEME;
payload: { toggle: boolean };
}
export type UserActions = IUserLogin | IUserLogout;
export type StateActions = UserActions | IThemeAction;
My action types
/**
* ActionTypes.tsx
*/
const LOGIN = "LOGIN";
const LOGOUT = "LOGOUT";
const THEME = "THEME";
// const LOGIN = "LOGIN"
// const LOGIN = "LOGIN"
export default Object.freeze({ LOGIN, LOGOUT, THEME });
export { LOGIN, LOGOUT, THEME };
A reducer function that only handles state concerning the state theme
import { THEME } from "../ActionTypes";
import { ITheme, StateActions } from "../interfaces";
const themeReducer = (theme: ITheme, action: StateActions) => {
switch (action.type) {
case THEME:
return { ...theme, ...action.payload };
default:
return theme;
}
};
export default themeReducer;
A reducer function that only handles state concerning the state user
import { LOGIN, LOGOUT } from "../ActionTypes";
import { IUser, StateActions } from "../interfaces";
const userReducer = (user: IUser, action: StateActions) => {
const { type, payload } = action;
switch (type) {
case LOGIN:
return { ...user, ...payload };
case LOGOUT:
return { ...user, username: "", active: false };
default:
return user;
}
};
export default userReducer;
For us to get access to the global state we have to wrap out app with the AppProvider
/**
* index.tsx
*/
import ReactDOM from "react-dom";
import App from "./App";
import AppProvider from "./state/AppProvider";
ReactDOM.render(
<AppProvider>
<App />
</AppProvider>,
document.getElementById("root")
);
In our header we can aacess the state of the app via a useContext
hook to get access to the state and pass our AppContext
instance to get current state of the application
/**
* Header.tsx
*/
import { useContext } from "react";
import { AppContext } from "../state/AppProvider";
const Header = () => {
const { state } = useContext(AppContext);
return (
<header>
<div className="left">LOGO</div>
<div className="right">
<ul>
<li>
<a href="/">My pages</a>
</li>
<li>
<a href="/">{state.user.active ? state.user.username : "Login"}</a>
</li>
</ul>
</div>
</header>
);
};
export default Header;
In the Home.tsx
using the useContext
hook we can destructure the context value object to get access to the state and the dispatch method for invoking our reducers
/**
* Home.tsx
*/
import { useContext } from "react";
import { LOGIN, LOGOUT } from "../state/ActionTypes";
import { AppContext } from "../state/AppProvider";
const Home = () => {
const { state, dispatch } = useContext(AppContext);
const { user } = state;
const hendleLogin = () => {
dispatch({
type: LOGIN,
payload: { active: true, username: "Mike" },
});
console.log(state);
};
const hendleLogout = () => {
dispatch({
type: LOGOUT,
payload: { username: "", active: false },
});
};
return (
<div className="home-container">
<p>{user.active ? user.username : "No user"}</p>
<div>
<button
className="login"
{...(user.active ? { disabled: true } : { disabled: false })}
onClick={hendleLogin}
>
Login
</button>
<button
className="logout"
{...(!user.active ? { disabled: true } : { disabled: false })}
onClick={hendleLogout}
>
Logout
</button>
</div>
</div>
);
};
export default Home;
/**
* App.tsx
*/
import "./App.css";
import Header from "./components/Header";
import Home from "./components/Home";
const App = () => {
return (
<div>
<Header />
<Home />
</div>
);
};
export default App;
Thank you so much for reading and hope you learn from this. Here is a link to the code on github Code sample
For any queries just give in the comments below
16