Modular Ducks - A design pattern for scalable redux architecture

ashish_r

Ashish Ranjan

Posted on May 10, 2021

Modular Ducks - A design pattern for scalable redux architecture

The Redux library is highly unopinionated. It lets us decide everything from store set-up and its contents to reducers. This is good because it gives us the flexibility to set it up as per the project requirements, but this flexibility is not always needed. We have to figure out the architecture ourselves, which is not an easy task.

I have worked with many different redux patterns and architectures, and I have found that none of the redux patterns are perfectly ideal. The ducks-pattern is prone to a circular dependency. The traditional folder-based approach requires you to separate actions, reducers, selectors, etc into multiple files that become cumbersome while developing and refactoring.

Redux toolkit provides an opinionated wrapper around redux and lets us do more with less code. But, the issue with the Redux toolkit is that the project structure becomes similar to ducks and is prone to a circular dependency. Redux toolkit has already warned us of this issue here.

In this article, I am sharing my approach for the architecture with Redux toolkit, which is circular dependency safe, and also handles refactoring with ease.

Project Structure

Let's start with the important redux components in the architecture.

Slices

  • Break your redux store based on the features of the app. With the Redux toolkit, we can use the createSlice API to create actions and reducers for an individual slice.
  • One thing to keep in mind is no two slices should import from one another. There can be a case when we might have to trigger reducers in two slices for one action. In that case, instead of importing action from one slice to another, create a common action in a separate file using createAction and register this in both the slices with extraReducers.
  • Export a constant key from the slice file to be used in the combineReducers to combine the reducers. Keeping the constant key in a slice file makes the store structure more predictable.
  • Keep selectors for all the keys of a slice file in the same slice file. You can also create separate selector files, but keeping them in the slice file makes refactoring a little easier. You can also use createGlobalStateSelector an ultra-light npm library to generate global state selectors from the local slice selectors. This approach reduces the refactoring efforts by quite a lot.

Common Actions

  • Based on the project structure we can have multiple common action files which will use createAction to export actions that can be used in multiple slices.
  • Common action files should not import from any other file (with redux components) in the project directory.
  • Common actions can be used inside slices, thunks, or our components.

Common Selectors

  • Just like common actions, we might need selectors from different slices to combine them into one selector (e.g. using createSelector to create a selector based on multiple selectors in different slices).
  • Keeping combined selectors of two different slices outside the slice file in a different selector file avoids the circular dependency issue.
  • Common selectors file will import selectors from the slices file and will export combined selectors to be used inside thunks or components.

Thunks

  • Thunk actions (or any redux middleware functions) should not be kept in the slice file. Thunks have access to the global state (with getState) and it might have to dispatch actions to multiple slices.
  • You can create multiple files for thunk actions (it is always better to have multiple files than having one giant file). This can also be divided based on the features.
  • Thunk action files can import from slice files (actions and selectors), common action files, and common selector files.

Import diagram

redux import diagram

Sample Code

// personalDetailsSlice.js

import { createSlice } from '@reduxjs/toolkit';
import createGlobalStateSelector from 'create-global-state-selector';
import { clearData } from './commonActions';

export const sliceKey = 'personalDetails';
const initialState = {
  name: 'Ashish',
  age: '26',
  isEligibleToDrink: false
};

const { actions, reducer } = createSlice({
  name: sliceKey,
  initialState,
  reducers: {
    setName(state, { payload }) {
      state.name = payload;
    },
    setAge(state, { payload }) {
      state.age = payload;
    },
    setDrinkingEligibilityBasedOnAge(state) {
      state.isEligibleToDrink = selectLocalAge(state) >= 18;
    }
  },
  extraReducers: {
    [clearData]: (state) => {
      state.isEligibleToDrink = null;
      state.age = null;
      state.name = null;
    }
  }
});

function selectLocalName(state) {
  return state.name;
}
function selectLocalAge(state) {
  return state.age;
}
function selectLocalIsEligibleToDrink(state) {
  return state.isEligibleToDrink;
}

export default reducer;
export const { setName, setAge, setDrinkingEligibilityBasedOnAge } = actions;

export const { selectName, selectAge, selectIsEligibleToDrink } = createGlobalStateSelector(
  {
    selectName: selectLocalName,
    selectAge: selectLocalAge,
    selectIsEligibleToDrink: selectLocalIsEligibleToDrink
  },
  sliceKey
);
Enter fullscreen mode Exit fullscreen mode

// educationalDetailsSlice.js

import { createSlice } from '@reduxjs/toolkit';
import createGlobalStateSelector from 'create-global-state-selector';
import { clearData } from './commonActions';

export const sliceKey = 'educationalDetails';
const initialState = {
  qualification: 'engineering'
};

const { actions, reducer } = createSlice({
  name: sliceKey,
  initialState,
  reducers: {
    setQualification(state, { payload }) {
      state.qualification = payload;
    }
  },
  extraReducers: {
    [clearData]: (state) => {
      state.qualification = null;
    }
  }
});

function selectLocalQualification(state) {
  return state.qualification;
}

export default reducer;
export const { setQualification } = actions;

export const { selectQualification } = createGlobalStateSelector(
  { selectQualification: selectLocalQualification },
  sliceKey
);
Enter fullscreen mode Exit fullscreen mode

// commonActions.js

import { createAction } from '@reduxjs/toolkit';

export const clearData = createAction('detail/clear');
Enter fullscreen mode Exit fullscreen mode

// commonSelectors.js

import { createSelector } from '@reduxjs/toolkit';
import { selectAge } from './personalDetailsSlice';
import { selectQualification } from './educationalDetailsSlice';

export const selectIsEligibleToWork = createSelector(
  selectAge,
  selectQualification,
  (age, qualification) => age >= 18 && qualification === 'engineering'
);
Enter fullscreen mode Exit fullscreen mode

// thunks.js

import { fetchQualification } from './api';
import { selectName } from './personalDetailsSlice';
import { setQualification } from './educationalDetailsSlice';
import { clearData } from './commonActions';

export const getQualification = () => (dispatch, getState) => {
  const state = getState();
  const name = selectName(state);
  fetchQualification(name)
    .then(({ qualification }) => dispatch(setQualification(qualification)))
    .catch(() => dispatch(clearData()));
};
Enter fullscreen mode Exit fullscreen mode

// store.js

import { createStore, combineReducers } from 'redux';
import personalDetailsReducer, { sliceKey as personalDetailsSliceKey } from './personalDetailsSlice';
import educationalDetailsReducer, { sliceKey as educationalDetailsSliceKey } from './educationalDetailsSlice';

const reducer = combineReducers({
  [personalDetailsSliceKey]: personalDetailsReducer, // 'personalDetails'
  [educationalDetailsSliceKey]: educationalDetailsReducer // 'educationalDetails'
});
const store = createStore(reducer);
export default store;
Enter fullscreen mode Exit fullscreen mode

The above example can scale well for large-scale projects. Pro-tip: Never import store directly anywhere except the root component file which passes store data to its child components with Provider. Use redux middlewares (like redux-thunk) when you need to access store data outside your component.

If you are worried about implementing the import rules in a large size project, check out the Dependency cruiser library.


Do share with us your way of creating a modular and scalable redux structure in the comments section.

If you're confused about anything related to this topic or have any questions, you can comment below or reach out to me on Twitter @code_ashish. 🙂


Thanks For Reading 😃

💖 💪 🙅 🚩
ashish_r
Ashish Ranjan

Posted on May 10, 2021

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

Sign up to receive the latest update from our blog.

Related