useReducer + useContext + Typescript. Without Redux!
Lior Bragilevsky
Posted on January 29, 2022
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:
TabMerger is a cross-browser extension that stores your tabs in a single place to save memory usage and increase your productivity.
Stores your tabs in one location to save memory usage and increase your productivity
π 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!
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.
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 contextconst[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:
// 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:
// 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
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.tsimport*asdndActionsfrom"../actions/dnd";import*asgroupsActionsfrom"../actions/groups";import*asheaderActionsfrom"../actions/header";import*asmodalActionsfrom"../actions/modal";importdndReducer,{initDnDState}from"./dnd";importgroupsReducer,{initGroupsState}from"./groups";importheaderReducer,{initHeaderState}from"./header";importmodalReducer,{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
*/constcombineReducers=<S=TRootState>(reducers:{[KinkeyofS]:TRootReducer<S[K]>}):TRootReducer<S>=>{return (state,action)=>{// Build the combined statereturn (Object.keys(reducers)asArray<keyofS>).reduce((prevState,key)=>({...prevState,[key]:reducers[key](prevState[key],action)}),state);};};exportconstrootState={header:initHeaderState,groups:initGroupsState,dnd:initDnDState,modal:initModalState};exportconstrootActions={header:headerActions,groups:groupsActions,dnd:dndActions,modal:modalActions};exportconstrootReducer=combineReducers({header:headerReducer,groups:groupsReducer,dnd:dndReducer,modal:modalReducer});
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.
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:
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.
constcombineReducers=<S=TRootState>(reducers:{[KinkeyofS]:TRootReducer<S[K]>}):TRootReducer<S>=>{return (state,action)=>{// Build the combined statereturn (Object.keys(reducers)asArray<keyofS>).reduce((prevState,key)=>({...prevState,[key]:reducers[key](prevState[key],action)}),state);};};exportconstrootReducer=combineReducers({header:headerReducer,groups:groupsReducer,dnd:dndReducer,modal:modalReducer});exporttypeTRootReducer<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
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.
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:
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:
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.tsimport{useCallback}from"react";import{TRootReducer}from"~/typings/reducers";functiongetTimestamp(){constd=newDate();// Need to zero pad each valueconst[h,m,s,ms]=[d.getHours(),d.getMinutes(),d.getSeconds(),d.getMilliseconds()].map((val)=>("0"+val).slice(-2));return`${h}:${m}:${s}.${ms}`;}constgetStyle=(color:string)=>`color: ${color}; font-weight: 600;`;exportdefaultfunctionuseReducerLogger(reducer:TRootReducer):TRootReducer{returnuseCallback((prevState,action)=>{constnextState=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();returnnextState;},[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
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 π