useReducer + useContext + Typescript. Without Redux!

lbragile

Lior Bragilevsky

Posted on January 29, 2022

useReducer + useContext + Typescript. Without Redux!

Ever find yourself questioning why it is that you need to use a library like Redux when React 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 - 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

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

GitHub logo lbragile / TabMerger

TabMerger is a cross-browser extension that stores your tabs in a single place to save memory usage and increase your productivity.

tabmerger logo

Build Forks Stars Watchers Release License
Chrome Users Chrome Rating Firefox Users Firefox Rating

Chrome WebStore Firefox WebStore Edge WebStore

Product Hunt

Stores your tabs in one location to save memory usage and increase your productivity

Demo

πŸ–‹ Description

Tired of searching through squished icons to find a tab you are sure is there?

TabMerger simplifies this clutter while increasing productivity in a highly organized and customizable fashion!

In one click, you can have everything in a common location, where you can then re-arrange into appropriate groups, add custom notes, and so much more All items are stored internally for you to use at a later time, even when you close the browser window(s) - reducing memory consumption and speeding up your machine. Lots of analytics keep you informed.

⭐ Review

If you found TabMerger useful, consider leaving a positive & meaningful review (Chrome | Firefox | Edge)
It would also mean a lot if you could 🌟 this repository on GitHub!

πŸ’Έ Donate

I would greatly appreciate any financial…

πŸ“‘ Table of Contents

  1. Redux In A Nutshell
  2. Root State, Actions & Reducers Magic
  3. Store Provider
  4. useSelector & useDispatch
  5. Bonus - useReducerLogger
  6. Conclusion

πŸ₯œ Redux In A Nutshell

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):

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

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:

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

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:

// some code
...
dispatch({ type: "DND_ACTIONS.UPDATE_IS_DRAGGING", payload: false })
...
// more code
Enter fullscreen mode Exit fullscreen mode

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:

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

Now you can call:

// some code
...
dispatch(updateIsDragging(false))
...
// more code
Enter fullscreen mode Exit fullscreen mode

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

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):

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

and here is the corresponding typing for each:

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

πŸ”¬ 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

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

export type TRootState = typeof rootState;
Enter fullscreen mode Exit fullscreen mode

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

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

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:

{ type: "UPDATE_DRAG_ORIGIN_TYPE", payload: string } | { type: "UPDATE_IS_DRAGGING", payload: boolean } | ... | <same for each slice>
Enter fullscreen mode Exit fullscreen mode

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:

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

So we need to use the following mapped type:

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

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

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

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

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

Redux has a handy 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:

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

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) 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

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

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:

const { isDragging } = useSelector((state) => state.dnd);
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

// src/hooks/useRedux.ts

...

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

  return dispatch;
};
Enter fullscreen mode Exit fullscreen mode

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:

const dispatch = useDispatch();

...

dispatch(updateIsDragging(false));

...
Enter fullscreen mode Exit fullscreen mode

πŸ™Œ Bonus - useReducerLogger

As seen above, in development mode, I use a useReducerLogger custom hook to log each dispatched action - based on the Redux Logger npm package.

Here is the logic for it:

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

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

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 πŸ₯‚

πŸ’– πŸ’ͺ πŸ™… 🚩
lbragile
Lior Bragilevsky

Posted on January 29, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related