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!
Top comments (1)
Hi, I'm a Redux maintainer. Unfortunately, I have concerns with several of the things you've shown here in this post - the code patterns shown are the opposite of how we recommend people use Redux today.
You should be using our official Redux Toolkit package and following our guidelines for using Redux with TypeScript correctly. That will eliminate all of the hand-written action types, action creators, and a lot of the other code you've shown, as well as simplifying the store setup process. You're also splitting the code across multiple files by type, and we recommend keeping logic in a single-file "slice" per feature.
In addition, we specifically teach that you should put more logic in reducers, let reducers control the state shape, model actions as "events" rather than "setters", and write meaningful action names..
Writing actions and reducers that involve "sending the entire updated entity as a payload, and then spreading it into the state" or with action types like
"UPDATE_STORE"
are not how we teach people to use Redux. We also tell users that you should not create unions of TS action types - there's no real benefit.In this specific case, part of the issue is that it looks like you're trying to live-update form state in Redux. We also recommend that you don't keep form state in Redux. Instead, let form state live in local components, and then dispatch a single action at the end with the resulting data when the user is done with the form.
(Also, as a side note: having a default case of
return {...state}
is also wrong, because you're unnecessarily creating a new state object when nothing actually changed.)I'd strongly recommend going through the "Redux Essentials" tutorial in the Redux docs to see how we want people to learn and use Redux today.