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:
lbragile / TabMerger
TabMerger is a cross-browser extension that stores your tabs in a single place 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!
πΈ Donate
I would greatly appreciate any financialβ¦
π Table of Contents
- Redux In A Nutshell
- Root State, Actions & Reducers Magic
- Store Provider
- useSelector & useDispatch
- Bonus - useReducerLogger
- 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;
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)
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:
// 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:
// 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.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:
// 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
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;
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>;
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>
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>
};
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];
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>;
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;
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);
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);
};
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;
};
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));
...
π 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]
);
}
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
anduseDispatch
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 theaction.type
(since the action's type will be inferred asstring
). - 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 π₯
Top comments (1)
useSelector
implementation differs fromreact-redux
, and is definitely way less performant. The issue is thatwill cause component re-render on each store change, which might make performance of your app very bad.