DEV Community

loading...

useReducer TypeScript React Context with useReducer and Typescript.

elisealcala profile image Elizabeth Alcalá ・5 min read

Just the code?

There are many options to handle state in react applications. Obviously you can use setState for some small logic, but what if you have a complex state to manage?

Maybe you will use Redux or MobX to handle this scenario, but there is also the option to use React Context, and you don't have to install another dependency.

Let's see how we can manage a complex state, with the Context API and Typescript.

In this tutorial we are building, a product list with a shopping cart counter.

First, create a new React project with create-react-app.

npx create-react-app my-app --template typescript
cd my-app/
Enter fullscreen mode Exit fullscreen mode

Next, create a new context.tsx file inside the src directory.

/*context.tsx*/

import React, { createContext } from 'react';

const AppContext = createContext({});
Enter fullscreen mode Exit fullscreen mode

You can initialize a context api as simple as that, with any value you want, in this case, I'm using an empty object.

Now let's create an initial state, with an empty list of products and the shopping cart counter to zero. Also, let's add some types for this.

/*context.tsx*/

import React, { createContext } from 'react';

type ProductType = {
  id: number;
  name: string;
  price: number;
}

type InitialStateType = {
  products: ProductType[];
  shoppingCart: number;
}

const initialState = {
  products: [],
  shoppingCart: 0,
}

const AppContext = createContext<InitialStateType>(initialState);
Enter fullscreen mode Exit fullscreen mode

Every product inside the product list is going to have an id, name, and price.

Now we will use reducers and actions to create and delete a product, and also increase the shopping cart counter by one. First, create a new file called reducers.ts.

/*reducers.ts*/

export const productReducer = (state, action) => {
  switch (action.type) {
    case 'CREATE_PRODUCT':
      return [
        ...state,
        {
          id: action.payload.id,
          name: action.payload.name,
          price: action.payload.price,
        }
      ]
    case 'DELETE_PRODUCT':
      return [
        ...state.filter(product => product.id !== action.payload.id),
      ]
    default:
      return state;
  }
}

export const shoppingCartReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_PRODUCT':
      return state + 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

A reducer function receives two arguments, the first one is the state, that we are passing when using useReducer hook, and the second one is an object that represents that events and some data that will change the state (action).

In this case, we create two reducers, one is for the products, and the other one for the shopping cart. On the product reducer, we add two actions, one for creating a new product and the other one to delete any product. For the shopping cart reducer, the only action we add is to increase the counter every time you add a new product.

As you can see, for creating a product we pass, the id, the name, and the price, and return our current state, with the new object. For deleting one, we just need and id and the return is the state but without the product that has this id.

Now let's change the context file to import these reducer functions.

/*context.tsx*/

import React, { createContext, useReducer } from 'react';
import { productReducer, shoppingCartReducer } from './reducers';

type ProductType = {
  id: number;
  name: string;
  price: number;
}

type InitialStateType = {
  products: ProductType[];
  shoppingCart: number;
}

const intialState = {
  products: [],
  shoppingCart: 0,
}

const AppContext = createContext<{
  state: InitialStateType;
  dispatch: React.Dispatch<any>;
}>({
  state: initialState,
  dispatch: () => null
});

const mainReducer = ({ products, shoppingCart }, action) => ({
  products: productReducer(products, action),
  shoppingCart: shoppingCartReducer(shoppingCart, action),
});

const AppProvider: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(mainReducer, initialState);

  return (
    <AppContext.Provider value={{state, dispatch}}>
      {children}
    </AppContext.Provider>
  )
}

export { AppContext, AppProvider };
Enter fullscreen mode Exit fullscreen mode

There's is a mainReducer function, that combines the two reducers that we are going to have (product reducer and shopping cart reducer), each one manages a select part of the state.

Also, we create the AppProvider component, and inside this, the useReducer hook takes this mainReducer and the initial state to return the state and the dispatch.

We pass these values into the AppContext.Provider, doing this we can access the state and dispatch with the useContext hook.

Next, add these types for the reducers and actions.

/*reducers.ts*/

type ActionMap<M extends { [index: string]: any }> = {
  [Key in keyof M]: M[Key] extends undefined
    ? {
        type: Key;
      }
    : {
        type: Key;
        payload: M[Key];
      }
};

export enum Types {
  Create = 'CREATE_PRODUCT',
  Delete = 'DELETE_PRODUCT',
  Add = 'ADD_PRODUCT',
}

// Product

type ProductType = {
  id: number;
  name: string;
  price: number;
}

type ProductPayload = {
  [Types.Create] : {
    id: number;
    name: string;
    price: number;
  };
  [Types.Delete]: {
    id: number;
  }
}

export type ProductActions = ActionMap<ProductPayload>[keyof ActionMap<ProductPayload>];

export const productReducer = (state: ProductType[], action: ProductActions | ShoppingCartActions) => {
  switch (action.type) {
    case Types.Create:
      return [
        ...state,
        {
          id: action.payload.id,
          name: action.payload.name,
          price: action.payload.price,
        }
      ]
    case Types.Delete:
      return [
        ...state.filter(product => product.id !== action.payload.id),
      ]
    default:
      return state;
  }
}

// ShoppingCart

type ShoppingCartPayload = {
  [Types.Add]: undefined;
}

export type ShoppingCartActions = ActionMap<ShoppingCartPayload>[keyof ActionMap<ShoppingCartPayload>];

export const shoppingCartReducer = (state: number, action: ProductActions | ShoppingCartActions) => {
  switch (action.type) {
    case Types.Add:
      return state + 1;
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

I take this approach from this post, basically we are checking wich action.type is used, and according to that, we generate the types for the payload.


NOTE

Another approach you can take is to use Discriminated unions like this.

type Action =
 | { type: 'ADD' }
 | { type: 'CREATE', create: object }
 | { type: 'DELETE', id: string };
Enter fullscreen mode Exit fullscreen mode

In the previous code, all of those types have a common property called type. Typescript will create type guards for discriminated unions and will let us now according to the type we are using wich other properties the object type has.

But for this tutorial we are using two common properties for our actions type and payload, and the payload object type changes according to the type, so discriminated union types won't work.


Now, let's import the types we define into the context file.

/*context.tsx*/

import React, { createContext, useReducer, Dispatch } from 'react';
import { productReducer, shoppingCartReducer, ProductActions, ShoppingCartActions } from './reducers';

type ProductType = {
  id: number;
  name: string;
  price: number;
}

type InitialStateType = {
  products: ProductType[];
  shoppingCart: number;
}

const initialState = {
  products: [],
  shoppingCart: 0,
}

const AppContext = createContext<{
  state: InitialStateType;
  dispatch: Dispatch<ProductActions | ShoppingCartActions>;
}>({
  state: initialState,
  dispatch: () => null
});

const mainReducer = ({ products, shoppingCart }: InitialStateType, action: ProductActions | ShoppingCartActions) => ({
  products: productReducer(products, action),
  shoppingCart: shoppingCartReducer(shoppingCart, action),
});


const AppProvider: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(mainReducer, initialState);

  return (
    <AppContext.Provider value={{state, dispatch}}>
      {children}
    </AppContext.Provider>
  )
}

export { AppProvider, AppContext };
Enter fullscreen mode Exit fullscreen mode

Don't forget to wrap your main component with the AppProvider.

/* App.tsx */

import React from 'react';
import { AppProvider } from './context';
import Products from './products';

const App = () => {
  <AppProvider>
    // your stuff
    <Products />
  </AppProvider>
}

export default App
Enter fullscreen mode Exit fullscreen mode

Create a Products component and inside this add the following code.

/* Products.tsx */

import React, { useContext } from 'react';
import { AppContext } from './context';
import { Types } from './reducers';

const Products = () => {
  const { state, dispatch } = useContex(AppContext);

  return (
    <div>
      <button onClick={() => {
        dispatch({
          type: Types.Add,
        })
      }}>
        click
        </button>
      {state.shoppingCart}
    </div>
  )
}

export default Products;
Enter fullscreen mode Exit fullscreen mode

Everything it's strongly typed now.

You can check the code here.

Sources.

https://medium.com/hackernoon/finally-the-typescript-redux-hooks-events-blog-you-were-looking-for-c4663d823b01

Discussion (22)

pic
Editor guide
Collapse
mannguyen0107 profile image
Man Nguyen

Greate post! although I have a question the way you have the type right now with the actions being a Discriminated unions of all the actions that created by ActionMap of all the reducers. This is very hard to manage when you have alot of reducers. There is no separation of concern. How would I go about moving each reducer into its own files. Then would I import each and every actions from all the reducer so I can use discriminated unions? I think there should be a better way, but I'm not knowledgable to do that yet. Do you have any suggestion?

Collapse
elisealcala profile image
Elizabeth Alcalá Author

Thanks! Yes I know if you have more than three reducers the types for the main reducer can increase, and be hard to manage.
I don't know how to improve the action type, this action has to be a union of product and shoppingCart actions because when using dispatch you can use either of both actions. To know exactly what action I need to use, maybe you could try with generics and conditional types. I think you could pass a generic type through Context to enable just certain types for actions and state. It's an idea, maybe works. I'll try to implement it.

Collapse
mannguyen0107 profile image
Man Nguyen

So I found this on a stack overflow post

function combineReducers(reducers) {
    return (state = {}, action) => {
        const newState = {};
        for (let key in reducers) {
            newState[key] = reducers[key](state[key], action);
        }
        return newState;
    };
}
Enter fullscreen mode Exit fullscreen mode

all you have to do is pass an object with key and value is the reducer. I'm searching about generic to convert this function to be type-safe. But if you have any idea please share. Thank you!

Collapse
mannguyen0107 profile image
Man Nguyen

As it turned out your way of doing the combine reducers also works for type-safe all you gotta do is use type assertion ie:

const mainReducer = ({ products, shoppingCart }: InitialStateType, action: ProductActions | ShoppingCartActions) => ({
  products: productReducer(products, action as ProductActions),
  shoppingCart: shoppingCartReducer(shoppingCart, action as ShoppingCartActions),
});
Enter fullscreen mode Exit fullscreen mode

By using the 'as' keyword there you can now get rid of the unions type on the action arg of your reducers

Thread Thread
elisealcala profile image
Elizabeth Alcalá Author

Hey, you are right, type assertion works well in this case, it makes the code look cleaner without the union types.

Thanks a lot.

Collapse
utshp08 profile image
reymart

Finally, I've found the exact thing that I was looking for. However, I still have a question. Can you show me how to initialize state from child nodes using this approach. What I wanted to achieve is something that is similar on this:
const [state, dispatch] = useReducer(
SampleReducer,
initialState,
() => {
return { field1: sampleValue1, field2: sampleValue2}
}

)

Thank you in advance.

Collapse
elisealcala profile image
Elizabeth Alcalá Author

Hey, I'm glad this helps you, according to react docs you can initialize a state passing a third argument. reactjs.org/docs/hooks-reference.h....

Collapse
utshp08 profile image
reymart

Yup, it same as what I also mentioned above. But, how would you achieve it in a context since the reducer is already part of the context?

Thread Thread
elisealcala profile image
Elizabeth Alcalá Author

oh. maybe you can pass the function through the context to the reducer? I'm going to try it and if I'm able to do it, I'll share the code :)

Thread Thread
utshp08 profile image
reymart

Yup, looking forward to it. That is what I wanted to achieve, I saw also in hooks document that we can create an init function that can be call through dispatch, however, not yet tried it.

Collapse
infosec812 profile image
Deven Phillips

Finally! A Context/Reducer tutorial which uses typescript and DOESNT just show how to change the theme name... Any chance you would be willing to add a custom Dispatcher which could resolve promises from REST API Calls? All of our API calls return Promises, and the dispatch method in React cannot resolve Promises, so I would love to see an example of a "Dispatcher" which wraps the default dispatch function to handle resolving Promises!

Collapse
mannguyen0107 profile image
Man Nguyen

Hi do you know how do I apply middleware with this config? I have been looking into this and i only got as far as create a single middleware and apply it but can't find how to apply multiple middleware

Collapse
elisealcala profile image
Elizabeth Alcalá Author

I haven't tried to apply multiple middlewares, let me try, and if I found a way I'll let you know.

Collapse
raphaelmansuy profile image
Raphael MANSUY

Great post !

Collapse
jingfeipeng profile image
Jeffer Jingfei Peng

Hey, excellent post. Could you show me how to handle asynchronous requests following this setup? Thank you so much in advance

Collapse
thepedroferrari profile image
Pedro Ferrari

Your article made it clearer to me a few things I was unsure about. Thank you @elisealcala , well done!

Collapse
vcardins profile image
Victor Cardins

Excellent post, very well written!! It fits perfectly to what I'm doing here!! :claps

Collapse
karniej profile image
Paweł Karniej

Great Article, thank you for that - I Was struggling with correct typings for store a little bit too long :P

Collapse
gindesim profile image
gindesim

how the create and delete works?
your code is not complete.

Collapse
elisealcala profile image
Elizabeth Alcalá Author

Hi, I update the code here. codesandbox.io/s/context-reducer-t..., check the List component. Basically, I create a state to handle the form and then just list the products.

import { AppContext } from "./context";
import { Types } from "./reducers";

const { state , dispatch } = React.useContext(AppContext);

const createProduct = () => {
  dispatch({
    type: Types.Create,
    payload: {
      id: Math.round(Math.random() * 10000),
      name: form.name,
      price: form.price
    }
  });
};

const deleteProduct = (id: number) => {
  dispatch({
    type: Types.Delete,
    payload: {
      id,
    }
  })
}
Collapse
damianesteban profile image
Damian Esteban

This is excellent. I love that it is fully typed.

Collapse
imakimaki profile image
AntiLun • Edited

Could anyone tell me why type ActionMap need to extends { [index: string]: any }? Thx so much.