Reduce your Redux boilerplate

gonzastoll

Gonzalo Stoll

Posted on September 6, 2021

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!

💖 💪 🙅 🚩
gonzastoll
Gonzalo Stoll

Posted on September 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Reduce your Redux boilerplate
react Reduce your Redux boilerplate

September 6, 2021