Cut Your Redux RTK Code In Half
Wrap all your slices with this clever Typescript hook.
If you’ve used Redux with RTK like I have then you’ve discovered the joys it brings. A greatly simplified store along with the introduction of the slices pattern makes working with Redux a treat instead of a chore. But the thing Redux RTK does that puts the greatest smile on my face is how much less boilerplate code is needed than with vanilla Redux.
As great a paradigm shift as Vanilla Redux was when it released you couldn’t help but feel in your bones that you were writing more code than you needed to. With Redux RTK, you simply needed less code to do the same thing–and that feels great! Less code means less time to write, less time to debug, and fewer chances for bugs.
In this post I am going to show you how Redux RTK, as nice as it is, has its own copy-pasta problem and what we can do about it.
RTK’s own boilerplate problem
Take a common example
- A slice that increments and decrements a number value
- A functional component that uses that slice to display the store value and provides 3 simple buttons to modify the value.
//store.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
}
});
export type RootState = ReturnType<typeof store.getState>;
// counterSlice.ts
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
export const counterSlice = createSlice({
name: "counter",
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
incrementByAmount: (state, action: PayloadAction<number>) =>
state + action.payload
}
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// counter.tsx
import { RootState } from "store";
import { useSelector, useDispatch } from "react-redux";
import { decrement, increment, incrementByAmount } from "counterSlice";
export const Counter = () => {
const dispatch = useDispatch();
const counter = useSelector((state: RootState) => state.counter.value);
return (
<>
<div>{counter}</div>
<button onClick={() => dispatch(increment())}>
Increment the counter!
</button>
<button onClick={() => dispatch(decrement())}>
Decrement the counter!
</button>
<button onClick={() => dispatch(incrementByAmount(10))}>
Increment by 10!
</button>
</>
);
};
export default Counter;
- Import useDispatch
- Import the slice actions
- Import useSelector
- Declare a dispatch variable with useDispatch
- Wrap any actions with the dispatch variable to call them
- Declare a variable for the state
Again and again and again!
Good software engineering sense tells me that when the same modules or functions are repeatedly imported together we can create a wrapper module or function to encapsulate them all.
Wrapping your slice in a hook
One way to accomplish this encapsulation is to wrap your slice in a hook that contains the dispatch, action, and selection logic and then outputs all the slice actions–with a dispatch baked into it–along with the slice state
It could look like this:
// counter.tsx
import { useCounterSlice } from "useCounterSlice";
export const Counter = () => {
const {
increment,
decrement,
incrementByAmount,
counter
} = useCounterSlice();
return (
<>
<div>{counter}</div>
<button onClick={increment}>Increment the counter!</button>
<button onClick={decrement}>Decrement the counter!</button>
<button onClick={() => incrementByAmount(5)}>+5!</button>
</>
);
};
Implementing a hook that wraps a slice to return values this way is very simple:
//useCounterSlice.ts
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "store";
import {
increment as incrementFromState,
decrement as decrementFromState,
incrementByAmount as incrementByAmountFromState
} from "../../counterSlice";
export const useCounterSlice = () => {
const dispatch = useDispatch();
const counter = useSelector((state: RootState) => state.counter);
const increment = () => {
dispatch(incrementFromState());
};
const decrement = () => {
dispatch(decrementFromState());
};
const incrementByAmount = (amount: number) => {
dispatch(incrementByAmountFromState(amount));
};
return { increment, decrement, incrementByAmount, counter } as const;
};
Still, we can do better.
The downside to this current approach
This approach is great, but what if I have 20 different slices? And what if each slice has 20 actions? So much work! I don’t want to write out the same wrapper hook pattern for every single one. And everytime I add a new action I’ll need to remember to add an equivalent wrapping action in the hook as well.
So lets write a dynamic utility hook that, given a slice and the name of the store property, can give us back the wrapped methods and store data that we need.
A better approach: useSliceWrapper
We’re going to create a function that takes a slice and the name of a store property as its parameters and returns an equivalent hook that contains all the actions and data we showed above in useCounterSlice.ts.
Doing that would simplify useCounterSlice to something like this:
// useCounterSlice.ts
import { counterSlice } from "../counterSlice";
import useSliceWrapper from "./useSliceWrapper";
export const useCounterSlice = () => useSliceWrapper(counterSlice, "counter");
// useSliceWrapper.ts
import { Slice as RTKSlice } from "@reduxjs/toolkit";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../store";
type StorePropertyNames = keyof RootState;
type StoreData<StorePropertyName extends StorePropertyNames> = {
[K in StorePropertyName]: RootState[K];
};
type WrappedSliceMethods<Slice extends RTKSlice> = {
[ActionName in keyof Slice["actions"]]: (
...args: Parameters<Slice["actions"][ActionName]>
) => void;
};
export const useSliceWrapper = <
Slice extends RTKSlice,
Name extends StorePropertyNames
>(
slice: Slice,
storePropertyName: Name
): WrappedSliceMethods<Slice> & StoreData<Name> => {
// TBD
}
- StorePropertyNames
- StoreData
- WrappedSliceMethods
- useSliceWrapper
- — This type contains the name of each property in the store. In this case, that’s just “counter”
- — This is the shape of the data we want to return
—This type takes a store property name and returns an object where that store property name is the key and the value is the store type for that property name. So if we used it like this type CounterData = StoreData<’counter’>, then CounterData would be { counter: number }.
- — This is the shape of the object containing all of our wrapped methods
— It takes a slice as it’s generic parameter and returns an object where the keys are the action names of that slice and the values are each a function that takes the actions parameters as its own parameters and returns void.
- — The actual function
—You can see the return type is WrappedSliceMethods & StoreData. The methods and data are inferred from the value of the slice and property name that is passed in.
Now let’s flesh out the inside of useSliceWrapper:
// useSliceWrapper.ts
import { Slice as RTKSlice } from "@reduxjs/toolkit";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../store";
type StorePropertyNames = keyof RootState;
type StoreData<StorePropertyName extends StorePropertyNames> = {
[K in StorePropertyName]: RootState[K];
};
type WrappedSliceMethods<Slice extends RTKSlice> = {
[ActionName in keyof Slice["actions"]]: (
...args: Parameters<Slice["actions"][ActionName]>
) => void;
};
export const useSliceWrapper = <
Slice extends RTKSlice,
Name extends StorePropertyNames
>(
slice: Slice,
storePropertyName: Name
): WrappedSliceMethods<Slice> & StoreData<Name> => {
const dispatch = useDispatch();
const { actions } = slice;
const data = useSelector<RootState, RootState[Name]>(
(state) => state[storePropertyName]
);
const dataOutput = { [storePropertyName]: data } as StoreData<Name>;
const methods = Object.keys(actions).reduce((acc, k) => {
const key = k as keyof typeof actions;
type Method = Slice["actions"][typeof key];
if (actions[key]) {
return {
...acc,
[key]: (...input: Parameters<Method>) => {
dispatch(actions[key](input));
}
};
}
return acc;
}, {} as WrappedSliceMethods<Slice>);
return { ...methods, ...dataOutput };
}
- dispatch and actions:
- data and dataOutput:
- methods:
- Finally, we output the methods and data!
- — dispatch will of course be used in our methods to dispatch our actions
— The actions are just the actions from the slice. For the counterSlice that will be increment, decrement, etc…
- — data is the actual data based on the store property name we pass in. So if we passed in ‘counter’ then data at this point should be the numeric value.
— dataOutput is the type guarded output that will be returned from the wrapper. It will consist of the store property name passed in along with the associated data. For counter that will be {counter: 1234}
- — Here is where we’re building the dynamic list methods that dispatch actions. Each method signature needs to match the action it’s wrapping and each method body needs to simply be the dispatch variable declared above calling the action itself.
— key is the name of the actions
— The Method type contains the type information for the action with the Parameters being the type we care about most.
Summary of benefits of useSliceWrapper
- Less to import in every functional component.
- Less to mock in unit tests when mocking the functional components.
- Don’t need to worry about wrapping every action in dispatch as the slice does it internally.
- When you add new actions to the slice they are automatically included in the slice wrapper.
You can find a working demo here: https://codesandbox.io/s/useslicewrapper-yl2bl0
About Intertech
Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Intertech teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Intertech employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Intertech is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.