DEV Community

Cover image for Reduce your Redux boilerplate
Gonzalo Stoll
Gonzalo Stoll

Posted on • Edited on

Reduce your Redux boilerplate

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: '',
};
Enter fullscreen mode Exit fullscreen mode

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,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
  };
  ...
}
Enter fullscreen mode Exit fullscreen mode

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,
      },
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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}));
  };
  ...
}
Enter fullscreen mode Exit fullscreen mode

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 = {};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
      },
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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}));
  };
  ...
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
markerikson profile image
Mark Erikson • Edited

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.