DEV Community

Cover image for React context API state management with typescript
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

React context API state management with typescript

Initial setup

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

My directory structure

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

/**
 * 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 };
Enter fullscreen mode Exit fullscreen mode

interfaces

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;
Enter fullscreen mode Exit fullscreen mode

Action types

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 };
Enter fullscreen mode Exit fullscreen mode

themeReducer.tsx

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;
Enter fullscreen mode Exit fullscreen mode

userReducer.tsx

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;
Enter fullscreen mode Exit fullscreen mode

index.tsx

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")
);
Enter fullscreen mode Exit fullscreen mode

header.tsx

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;
Enter fullscreen mode Exit fullscreen mode

Home.tsx

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;
Enter fullscreen mode Exit fullscreen mode

App.tsx

/**
 * 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;
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)