useReducer + useContext + Typescript. Without Redux!
Tags: react, typescript, redux, javascript
Published: 2022-01-29 04:19:18

Ever find yourself questioning why it is that you need to use a library like [Redux](https://redux.js.org/) when [React](https://reactjs.org/) already has this functionality in the form of hooks?

That's right, React comes with 2 hooks that can be leveraged to reproduce Redux-like functionality:

- `useReducer` is an "alternative" `useState` that is often used 

> when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one

This sounds pretty useful for the reducer portion of Redux right?

- `useContext` allows you to pass information (state in our case) between components even if they are not direct siblings. This avoids a well known side-effect - [prop drilling](https://kentcdodds.com/blog/prop-drilling) - making it easier to scale your codebase since there is a "global store" (just like in Redux 😃)

Wait, what about typing? Doesn't Redux already handle all of this for us with their wonderful `combineReducers` generic?

Yes, but that requires 2 extra modules (Redux & React-Redux) for a "simple" function - `node_modules` is already large enough. 

Also, wouldn't you feel better as a developer if you actually knew what is going on behind the scene? Or dare I say, how to actually type it yourself?

Those were trick questions, the answer to both is **yes** and you will learn a bunch by taking _action_ and _reducing_ the number of modules you use in your project 😉

## Sample Repository <a name="sample"/>

You can see the full codebase for what I am about to share in my recent project:

{% github lbragile/TabMerger %}

## 📑 Table of Contents

1. [Redux In A Nutshell](#redux)
2. [Root State, Actions & Reducers Magic](#combine)
  * [State](#state)
  * [Actions](#actions)
  * [Reducers](#reducers)
3. [Store Provider](#provider)
4. [useSelector & useDispatch](#custom)
  * [useSelector](#selector)
  * [useDispatch](#dispatch)
5. [Bonus - useReducerLogger](#bonus)
6. [Conclusion](#conclusion)

## 🥜 Redux In A Nutshell <a name="redux"/>

As you should know, reducers are functions that essentially start with some initial state and, based on the `action.type` and/or `action.payload`, update said state.

For example (ignore the typing for now):
```typescript
// src/store/reducers/dnd.ts

import { TRootActions } from "~/typings/reducers";

export const DND_ACTIONS = {
  UPDATE_DRAG_ORIGIN_TYPE: "UPDATE_DRAG_ORIGIN_TYPE",
  UPDATE_IS_DRAGGING: "UPDATE_IS_DRAGGING",
  RESET_DND_INFO: "RESET_DND_INFO"
} as const;

interface IDnDState {
  dragType: string;
  isDragging: boolean;
}

export const initDnDState: IDnDState = {
  dragType: "tab-0-window-0",
  isDragging: false
};

const dndReducer = (state = initDnDState, action: TRootActions): IDnDState => {
  switch (action.type) {
    case DND_ACTIONS.UPDATE_DRAG_ORIGIN_TYPE:
      return {
        ...state,
        dragType: action.payload
      };

    case DND_ACTIONS.UPDATE_IS_DRAGGING:
      return {
        ...state,
        isDragging: action.payload
      };

    case DND_ACTIONS.RESET_DND_INFO:
      return initDnDState;

    default:
      return state;
  }
};

export default dndReducer;
```

As your project grows, you will have multiple reducers for different stages - these are known as **slices** in Redux. In TabMerger's case, I created reducers for `dnd` (saw above), `header`, `groups`, and `modal` - for a total of 4 slices.

Redux provides a way to dispatch actions which use these reducers. Guess what, `useReducer` does also... in fact, it is the second element in the array that gets destructured:

```typescript
// rootReducer and rootState are not defined yet...
// ... I show them here for context
const [state, dispatch] = useReducer(rootReducer, rootState)
```

**Side Note**: `useReducer` is actually a generic hook, but if you type everything properly (as I will show below) it's type will be inferred based on the arguments provided.

This `dispatch` acts similarly to the `setState` of a `useState` hook, and you supply the action object which is consumed in the reducer. For example:

```typescript
// some code
...
dispatch({ type: "DND_ACTIONS.UPDATE_IS_DRAGGING", payload: false })
...
// more code
```

However, it is common practice to also make "Action Creators" for each reducer _case_, to simplify the above `dispatch` call. These action creators are just "wrappers" that return the expected type and payload object and allow you to simply call the function and pass the payload as needed. For example:

```typescript
// src/store/actions/dnd.ts
import { DND_ACTIONS } from "~/store/reducers/dnd";

export const updateDragOriginType = (payload: string) => ({ type: DND_ACTIONS.UPDATE_DRAG_ORIGIN_TYPE, payload });

export const updateIsDragging = (payload: boolean) => ({ type: DND_ACTIONS.UPDATE_IS_DRAGGING, payload });

export const resetDnDInfo = () => ({ type: DND_ACTIONS.RESET_DND_INFO });
```

Now you can call:
```typescript
// some code
...
dispatch(updateIsDragging(false))
...
// more code
```

Neat right?

This is the reasoning behind making the `DND_ACTIONS` object - you specify your types in one place and then your IDE can help with auto completion, which prevents you from making grammatical mistakes that can lead to bugs.

You are probably wondering, why the `as const` casting for the `DND_ACTIONS` object?

This is to provide typescript with strict typing in our action creators. Without the casting, each value in the object will have a general **string** type. With the casting, each value will be _readonly_ and exactly the value we specify. This allow TypeScript to deduce what the payload type is for each case in our reducer function since the action creator "type" property value is exactly matching and not just a generic string value.


## 🎉 Root State, Actions & Reducers Magic <a name="combine"/>

Those that are keen, would have noticed that in addition to exporting the reducer (**default export**), I also exported the initial state as a **named export**. Again, this is done for all slices.

Why?

As discussed above, we need to combine these reducers right?

Well, to do this, we also need to combine the initial state "slices".

Here is how (step by step analysis follows):

```typescript
// src/store/index.ts

import * as dndActions from "../actions/dnd";
import * as groupsActions from "../actions/groups";
import * as headerActions from "../actions/header";
import * as modalActions from "../actions/modal";

import dndReducer, { initDnDState } from "./dnd";
import groupsReducer, { initGroupsState } from "./groups";
import headerReducer, { initHeaderState } from "./header";
import modalReducer, { initModalState } from "./modal";

import { ReducersMap, TRootReducer, TRootState } from "~/typings/reducers";

/**
 * Takes in reducer slices object and forms a single reducer with the combined state as output
 * @see https://stackoverflow.com/a/61439698/4298115
 */
const combineReducers = <S = TRootState>(reducers: { [K in keyof S]: TRootReducer<S[K]> }): TRootReducer<S> => {
  return (state, action) => {
    // Build the combined state
    return (Object.keys(reducers) as Array<keyof S>).reduce(
      (prevState, key) => ({
        ...prevState,
        [key]: reducers[key](prevState[key], action)
      }),
      state
    );
  };
};

export const rootState = {
  header: initHeaderState,
  groups: initGroupsState,
  dnd: initDnDState,
  modal: initModalState
};

export const rootActions = {
  header: headerActions,
  groups: groupsActions,
  dnd: dndActions,
  modal: modalActions
};

export const rootReducer = combineReducers({
  header: headerReducer,
  groups: groupsReducer,
  dnd: dndReducer,
  modal: modalReducer
});
```

and here is the corresponding typing for each:

```typescript
// src/typings/redux.d.ts

import { Reducer } from "react";

import { rootActions, rootState } from "~/store";

type ActionsMap<A> = {
  [K in keyof A]: A[K] extends Record<keyof A[K], (...arg: never[]) => infer R> ? R : never;
}[keyof A];

export type TRootState = typeof rootState;

export type TRootActions = ActionsMap<typeof rootActions>;

export type TRootReducer<S = TRootState, A = TRootActions> = Reducer<S, A>;
```

### 🔬 Analysis <a name="analysis"/>

Lets break down the above as there is quite a bit of information there and it is the most critical part for avoiding Redux completely.

#### 1. State <a name="state"/>

```typescript
export const rootState = {
  header: initHeaderState,
  groups: initGroupsState,
  dnd: initDnDState,
  modal: initModalState
};

export type TRootState = typeof rootState;
```

The "root state" is easiest to form as it is just an object with the slices as keys and the initial state values (exported from the reducers) as the corresponding value.

The type of the "root state" is also simple, as it is just the type of this object.

#### 2. Actions <a name="actions"/>

```typescript
export const rootActions = {
  header: headerActions,
  groups: groupsActions,
  dnd: dndActions,
  modal: modalActions
};

export type ActionsMap<A> = {
  [K in keyof A]: A[K] extends Record<keyof A[K], (...arg: never[]) => infer R> ? R : never;
}[keyof A];

export type TRootActions = ActionsMap<typeof rootActions>;
```

The "root actions" is a again just the keys of each slice, with the corresponding combined (`import * as value from "..."`) imported action creators object.

Its type is a bit more involved.

We want our reducers' action argument to contain all possible action creator types so that when we use a value for the `action.type`, TypeScript can cross reference all the action creators to find the correct payload typing for this `action.type`. Obviously each `action.type` should be unique for this to work properly. To do this, we generate a _union_ type consisting of the return types of each of the action creators:

```typescript
{ type: "UPDATE_DRAG_ORIGIN_TYPE", payload: string } | { type: "UPDATE_IS_DRAGGING", payload: boolean } | ... | <same for each slice>
```

Notice, how the type of the "type" property is not just `string`, but rather the exact value provided in the `DND_ACTIONS` object.

Currently the "root actions" object looks something like:

```typescript
// typeof rootActions

{
  header: <headerActions>,
  groups: <groupsActions>,
  dnd: {
    updateDragOriginType: (payload: string) => { type: "UPDATE_DRAG_ORIGIN_TYPE";  payload: string; },
    updateIsDragging: (payload: boolean) => { type: "UPDATE_IS_DRAGGING"; payload: boolean; },
    resetDnDInfo: () => { type: "RESET_DND_INFO" }
  },
  modal: <modalActions>
};
```

So we need to use the following mapped type:

```typescript
export type ActionsMap<A> = {
  [K in keyof A]: A[K] extends Record<keyof A[K], (...arg: never[]) => infer R> ? R : never;
}[keyof A];
```

This maps each slice in "root actions" and checks if it's value's type is an object that contains the key/value pair where the value is a function with any number of arguments of any type. If it is, then we set the return type of that _value_ function to `R` (whatever it is) and return it. Otherwise we return `never`. Lastly, as we still have an object (`Record<[slice], [union of slice's action creator return types]>`) we use `[keyof A]` to create a union of these slices - producing the desired type.

#### 3. Reducers <a name="reducers"/>

Finally, what I consider the most challenging is the combined reducers.

```typescript
const combineReducers = <S = TRootState>(reducers: { [K in keyof S]: TRootReducer<S[K]> }): TRootReducer<S> => {
  return (state, action) => {
    // Build the combined state
    return (Object.keys(reducers) as Array<keyof S>).reduce(
      (prevState, key) => ({
        ...prevState,
        [key]: reducers[key](prevState[key], action)
      }),
      state
    );
  };
};

export const rootReducer = combineReducers({
  header: headerReducer,
  groups: groupsReducer,
  dnd: dndReducer,
  modal: modalReducer
});

export type TRootReducer<S = TRootState, A = TRootActions> = Reducer<S, A>;
```

First, the `combineReducers` generic is a function that takes in the "root reducer" object (separated into slices as with state and action creators) and, as the name implies, combines them into a properly typed, single reducer. This is accomplished by looping over the slices and forming the combined state via JavaScript's `Array.prototype.reduce()`. Then the "root reducer" is simply a function that, as with any other reducer, takes a state (`rootState`) and action (`rootActions`) as arguments and returns a new "root state".

The typing for the "root reducer" is simple and just leverages React's built in `Reducer` generic. By default, I pass the `TRootState` and `TRootActions` to it. For the argument to the `combineReducers` we need to supply the reducer corresponding to each slice. This is accomplished via a mapped type for each slice from the "state" argument (generally `TRootState`) to the corresponding reducer. Note that the action type stays the union of all action creators for each slice as it is assumed that `action.type` is globally unique across all reducers.

Now that we got the tough part out of the way, lets set up our store!

## 🏪 Store Provider <a name="provider"/>

Redux has a handy [Provider](https://react-redux.js.org/api/provider) into which you pass your state (store) and the whole app can use it.

This can be accomplished with `useContext` and the state (along with the dispatch) can be created with `useReducer` as mentioned previously.

Here is TabMerger's `StoreProvider` component:

```typescript
// src/store/configureStore.tsx

import { createContext, Dispatch, useMemo, useReducer } from "react";

import useReducerLogger from "~/hooks/useReducerLogger";
import { rootReducer, rootState } from "~/store/reducers";
import { TRootActions, TRootState } from "~/typings/reducers";

export const ReduxStore = createContext<{ state: TRootState; dispatch: Dispatch<TRootActions> }>({
  state: rootState,
  dispatch: () => ""
});

const StoreProvider = ({ children }: { children: JSX.Element }) => {
  const loggedReducer = useReducerLogger(rootReducer);

  const [state, dispatch] = useReducer(process.env.NODE_ENV === "development" ? loggedReducer : rootReducer, rootState);

  const store = useMemo(() => ({ state, dispatch }), [state]);

  return <ReduxStore.Provider value={store}>{children}</ReduxStore.Provider>;
};

export default StoreProvider;
```

What is done here?

A global context is created - `ReduxStore` - using React's `createContext` generic and is set with non important default values (can be anything as long as the typing makes sense). This context is typed to be an object with state (`TRootState`) and dispatch (`React.Dispatch<TRootActions>`) properties. 

The component itself takes a `children` prop (since it will wrap our entire app) and uses `useReducer` to create the `state` and `dispatch` values that will be passed to the context created above (and used throughout the app). The `useReducer` takes either a logging root reducer (see [bonus section](#bonus)) or a regular root reducer depending on the environment and the root state as arguments. Due to the previous typing for both arguments, the `useReducer` can infer the respective types and thus does not need to be typed additionally.

Next the context object is memoized with `useMemo` to avoid redundant re-renders of all components. Finally, the memoized value is passed to the provider for the "children" (our app) to consume.

## 🦄 useSelector & useDispatch <a name="custom"/>

Redux also has `useSelector` and `useDispatch` hooks which can be easily created with our new context, saving us from having to import the context each time.

### useSelector <a name="selector"/>

The `useSelector` hook simply takes a callback function which returns a specific state item from the "root state" object.

For example, to retrieve the `isDragging` property from the `dnd` state item, we can do:

```typescript
const { isDragging } = useSelector((state) => state.dnd);
```

How to make this? How to type this? Let's see:

```typescript
// src/hooks/useRedux.ts
import { useContext } from "react";

import { ReduxStore } from "~/store/configureStore";
import { TRootState } from "~/typings/reducers";

type TypedUseSelectorHook = <U>(cb: (state: TRootState) => U) => U;

export const useSelector: TypedUseSelectorHook = (cb) => {
  const { state } = useContext(ReduxStore);

  return cb(state);
};
```

As can be seen, the `useSelector` is just a function which takes a callback as an argument. We retrieve the state from our context, and pass it to the callback - which extracts the needed item in our codebase as shown in the above example.

To type the `useSelector` we let TypeScript do its thing by "inferring" the return type of whatever callback we pass to it, storing it in `U` and then setting the return of the `useSelector` to match this type (`U`). This ensures proper typing throughout our app.

### useDispatch <a name="dispatch"/>

The `useDispatch` hook is even simpler as it can just return our context's dispatch function:


```typescript
// src/hooks/useRedux.ts

...

export const useDispatch = () => {
  const { dispatch } = useContext(ReduxStore);

  return dispatch;
};
```

This dispatch function will be properly typed as it comes from the typed context (`ReduxStore`). It can then be called inside any component as follows:

```typescript
const dispatch = useDispatch();

...

dispatch(updateIsDragging(false));

...
```

## 🙌 Bonus - useReducerLogger <a name="bonus"/>

As seen above, in development mode, I use a `useReducerLogger` custom hook to log each dispatched action - based on the [Redux Logger](https://www.npmjs.com/package/redux-logger) npm package.

Here is the logic for it:

```typescript
// src/hooks/useReducerLogger.ts

import { useCallback } from "react";

import { TRootReducer } from "~/typings/reducers";

function getTimestamp() {
  const d = new Date();

  // Need to zero pad each value
  const [h, m, s, ms] = [d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()].map((val) =>
    ("0" + val).slice(-2)
  );

  return `${h}:${m}:${s}.${ms}`;
}

const getStyle = (color: string) => `color: ${color}; font-weight: 600;`;

export default function useReducerLogger(reducer: TRootReducer): TRootReducer {
  return useCallback(
    (prevState, action) => {
      const nextState = reducer(prevState, action);

      console.groupCollapsed(
        `%c action %c${action.type} %c@ ${getTimestamp()}`,
        getStyle("#9e9e9e"),
        getStyle("initial"),
        getStyle("#9e9e9e")
      );

      console.info("%c prev state", getStyle("#9e9e9e"), prevState);
      console.info("%c action", getStyle("#00a7f7"), action);
      console.info("%c next state", getStyle("#47b04b"), nextState);

      console.groupEnd();

      return nextState;
    },
    [reducer]
  );
}
```

This hook simply uses console groups to create collapsed groups that contain the necessary information in each dispatch. This hook is also memoized to re-render only when a the root reducer changes (state or dispatch)

## 🏁 Conclusion <a name="conclusion"/>

The key takeaways are:

- Redux's core functionality can be re-created with `useReducer` & `useContext`
- Helper hooks (abstractions), like `useSelector` and `useDispatch` are relatively simple to create
- Typescript (when used correctly) can provide an incredible developer experience
- `as const` is helpful for instances where strong typing is required - as in action creators. Without it, there would be no way to deduce each action's payload typing based on the `action.type` (since the action's type will be inferred as `string`).
- Mapped types paired with `infer` are extremely useful when working with data whose type is not known in advance - such as the payload in action creators

Don't get me wrong, Redux is great! However, I think it is much more empowering (as a developer) when you have full control of *everything*. 

Leveraging React's `useContext` and `useReducer` is a great way to completely eliminate Redux. Typescript comes to the rescue if you also want your codebase to be strongly typed - I highly recommend this as it prevent careless errors.

If you feel inspired and/or find TabMerger interesting, feel free to contribute as it is open source 🙂

Cheers 🥂