Reduce your Redux boilerplate
Gonzalo Stoll
Posted on September 6, 2021
Please note: This article assumes prior knowledge of how React works, how Redux works, and how the two combined work. Actions, reducers, store, all that jazz.
I’m with you on this one… creating aaall the boilerplate that’s necessary to setup your Redux store is a pain in the 🥜. It gets even worse if you have a huge store to configure, which might be the sole reason why you decide to use Redux in the first place. Over time, your store configuration can grow exponentially.
So let’s cut right to the chase. A Frontend architect (yeah, he knows stuff) recently taught me a good way to reduce
(😉) your boilerplate considerably. And it goes like this:
Store
Let’s pretend that in some part of our application we have a form where the user has to fill up some configuration data, click a button and then generate a kind of report of sorts. For that, let’s consider the following store:
// store/state.js
export const INITIAL_STATE = {
firstName: '',
lastName: '',
startDate: '',
endDate: '',
};
Actions
Now the general convention will tell you: ok, let’s create an action for each state entity to update it accordingly. That’ll lead you to do something like:
// store/actions.js
export const UPDATE_FIRST_NAME = 'UPDATE_FIRST_NAME';
export const UPDATE_LAST_NAME = 'UPDATE_LAST_NAME';
export const UPDATE_START_DATE = 'UPDATE_START_DATE';
export const UPDATE_END_DATE = 'UPDATE_END_DATE';
export const actions = {
updateFirstName(payload) {
return {
type: UPDATE_FIRST_NAME,
payload,
};
},
updateLastName(payload) {
return {
type: UPDATE_LAST_NAME,
payload,
};
},
updateStartDate(payload) {
return {
type: UPDATE_START_DATE,
payload,
};
},
updateEndDate(payload) {
return {
type: UPDATE_END_DATE,
payload,
};
},
};
You can see the boilerplate growing, right? Imagine having to add 7 more fields to the store 🤯
Reducer
That takes us to the reducer, which in this case will end up something like:
// store/reducer.js
import * as actions from './actions';
import {INITIAL_STATE} from './state';
export default function reducer(state = INITIAL_STATE, action) {
switch (action.type) {
case actions.UPDATE_FIRST_NAME:
return {
...state,
firstName: action.payload,
};
case actions.UPDATE_LAST_NAME:
return {
...state,
lastName: action.payload,
};
case actions.UPDATE_START_DATE:
return {
...state,
startDate: action.payload,
};
case actions.UPDATE_END_DATE:
return {
...state,
endDate: action.payload,
};
default:
return state;
}
}
Dispatch
So, now that we have our fully boilerplated store in place, we’ll have to be react accordingly and dispatch actions whenever it’s needed. That’ll look somewhat similar to:
// components/MyComponent.js
import {actions} from '../store/actions';
export default function MyComponent() {
...
const firstNameChangeHandler = value => {
dispatch(actions.updateFirstName(value));
};
const lastNameChangeHandler = value => {
dispatch(actions.updateLastName(value));
};
const startDateChangeHandler = value => {
dispatch(actions.updateStartDate(value));
};
const endDateChangeHandler = value => {
dispatch(actions.updateEndDate(value));
};
...
}
The solution
We can reduce considerably our boilerplate by creating only one action that takes care of updating the entire store. Thus reducing the amount of actions and consequently the size of the reducer.
How you may ask? By sending the entire updated entity as a payload
, and then spreading it into the state. Confused? Let's break it down.
Action
As mentioned before, only one action will be responsible for targeting the state.
// store/state.js
export const UPDATE_STORE = 'UPDATE_STORE';
export const actions = {
updateStore(entity) {
return {
type: UPDATE_STORE,
payload: {
entity,
},
};
},
};
entity
in this case makes reference to any entity located in the state. So, in our case, that could be firstName
, lastName
, startDate
or endDate
. We'll receive that entity with its corresponding updated value, and spread it in the state.
Reducer
As stated before, only one case will be fired. This case handles the updating of the state.
// store/reducer.js
import {UPDATE_STORE} from './actions';
import {INITIAL_STATE} from './state';
export default function reducer(state = INITIAL_STATE, action) {
switch (action.type) {
case UPDATE_STORE: {
const {entity} = action.payload;
return {
...state,
...entity,
};
}
default:
return state;
}
}
Dispatch
And finally, only one event handler with a single dispatch function:
// components/MyComponent.js
import {actions} from '../store/actions';
export default function MyComponent() {
...
// This will in turn be used as
// onClick={event => onChangeHandler('firstName', event.target.value)}
const onChangeHandler = (entity, value) => {
dispatch(actions.updateStore({[entity]: value}));
};
...
}
And with that, you’ve successfully created a store with A LOT less boilerplate, thus incrementing your productivity to focus on more important things and functionalities.
Are you a TypeScript fan as I am? Then continue reading!
TypeScript bonus!
Let’s try to puppy up this store with some TS support. We all know why TS is important. It’ll force you to write better code, makes it easy to debug by providing a richer environment for spotting common errors as you type the code instead of getting the ugly error on screen leading you to a thorough investigation of where the (most of the times) minor problem was.
So with that said, let’s get to it!
Store
If all the values are going to be empty strings by default, then we better just add them as optionals (undefined
) and only set the values on change:
// store/state.ts
export interface State {
firstName?: string;
lastName?: string;
startDate?: string;
endDate?: string;
}
const INITIAL_STATE: State = {};
Actions
We can make use of the Partial
utility type that TypeScript provides. It basically constructs a type with all the properties fed to it set to optional. This is precisely what we need, given that we'll use them conditionally.
So, create a types.ts
file where we'll define all our actions blueprints. In our case we only have the one action, but that can change with time with bigger states.
// store/types.ts
import {State} from './state';
interface UpdateStore {
type: 'store/UPDATE';
payload: {
entity: Partial<State>;
};
}
export type ActionType = UpdateStore; // union type for future actions
This file will export a Union Type constructed by all the action blueprints we've already set. Again, in our case we only have one action, but that can change with time and end up with something like:
export type ActionType = UpdateStore | UpdateAcme | UpdateFoo;
Back to the action creators, we'll again make use of the Partial
utility type.
// store/actions.ts
import {ActionType} from './types';
import {State} from './state';
export const actions = {
updateStore(entity: Partial<State>): ActionType {
return {
type: 'store/UPDATE',
payload: {
entity,
},
};
}
};
Reducer
We'll make use of the newly created Union Type containing all of our action blueprints. It's a good idea to give the reducer a return type of the State
type to avoid cases where you stray from the state design.
// store/reducer.ts
import {ActionType} from './types';
import {INITIAL_STATE, State} from './state';
export default function reducer(state = INITIAL_STATE, action: ActionType): State {
switch (action.type) {
case 'store/UPDATE': {
const {entity} = action.payload;
return {
...state,
...entity,
};
}
default:
return state;
}
}
Dispatch
And finally, our component is ready to use all this autocompletion beauty we've already set.
// components/MyComponent.tsx
import {actions} from '../store/actions';
import {State} from '../store/state';
export default function MyComponent() {
...
const onChangeHandler = <P extends keyof State>(
entity: P,
value: State[P]
) => {
dispatch(actions.updateStore({[entity]: value}));
};
...
}
Now you have a fully flexible store, where you can add all the properties it requires without worrying about adding actions and reducer cases.
I sincerely hope this helps the same way it helped me :)
Thank you for reading!
Posted on September 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.