Redux demystified - 2: combineReducers

Understand how redux deals with multiple reducers and the implementation of combineReducers method

This post is a continuation of Redux demystified - 1. We understood the implementation of createStore and how we can manage the state when there is a single reducer. But that was just a simple example. Usually, any web app will have many different logically separated state objects which would need their own reducers.

Eventually Redux will accept only a single state object and a single reducer function that manages the overall state. So in order to combine multiple state objects and reducers, the redux provides a function named.... combineReducers, wasn't too hard to guess I believe.

We will add a new functionality to our example in the previous article, where we can add members to any of the two teams - Avengers and Justice League.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <script
            src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.2.0/redux.min.js"
            integrity="sha512-1/8Tj23BRrWnKZXeBruk6wTnsMJbi/lJsk9bsRgVwb6j5q39n0A00gFjbCTaDo5l5XrPVv4DZXftrJExhRF/Ug=="
            crossorigin="anonymous"
            referrerpolicy="no-referrer"
        ></script>
        <title>Heroes</title>
    </head>
    <body>
        <main class="members">
            <label for="name">Member Name:</label>
            <input type="text" id="name" autocomplete="name" placeholder="enter member name"/>
            <label for="team">Choose Team</label>
            <select id="team" name="team">
                <option value="Avengers">Avengers</option>
                <option value="Justice League">Justice League</option>
            </select>
            <button id="add">Add</button>
            <ul class="team-list"></ul>
            <label>Filters: 
                <ul class="filters">
                    <li class="filter selected">All</li>
                    <li class="filter">Avengers</li>
                    <li class="filter">Justice League</li>
                </ul>
            </label>

        </main>
        <style>
            .member,
            .members,
            .team-list {
                display: flex;
                justify-content: center;
                align-items: center;
            }

            .members,
            .team-list {
                flex-direction: column;
            }

            .member {
                gap: 10px;
            }

            .filters {
                list-style: none;
                display: flex;
                justify-content: center;
                align-items: center;
                gap: 10px;
            }

            .filter {
                text-decoration: underline;
                color: blue;
                cursor: pointer;
            }

            .selected {
                text-decoration: none;
                color: black;
                cursor: default;
            }

        </style>
        <script>
            // member reducer function
            const membersReducer = (state = [], action) => {
                switch (action.type) {
                    case 'ADD_MEMBER':
                        return [
                            ...state,
                            action.payload.member
                        ]
                    case 'REMOVE_MEMBER':
                        return state.filter(
                            member =>
                                member.id !== parseInt(action.payload.id)
                        )
                    default:
                        return state
                }
            }

            // filter reducer function
            const filterReducer = (state = 'All', action) => {
                switch (action.type) {
                    case 'FILTER_MEMBERS':
                        return action.payload.filter
                    default:
                        return state
                }
            }

            const combinedReducer = Redux.combineReducers({
                members: membersReducer,
                filter: filterReducer,
            })

            const { getState, dispatch, subscribe } =
                Redux.createStore(combinedReducer)

            const membersList = document.querySelector('.team-list')
            const input = document.querySelector('#name')
            const addMember = document.querySelector('#add')
            const selectedTeam = document.querySelector('#team')
            const filters = document.querySelector('.filters')

            let count = 0

            // update filter on click
            const handleFilterMembers = e => {
                if (e.target.classList.contains('filter')) {
                    filters.querySelector('.selected').classList.remove('selected')
                    e.target.classList.add('selected')
                    dispatch({
                        type: 'FILTER_MEMBERS',
                        payload: {
                            filter: e.target.innerText,
                        },
                    })
                }
                renderMembers()
            }

            // enter key event handler
            const enterMemberHandler = e => {
                if (e.key === 'Enter') addMemberHandler()
            }

            // add button click event handler
            const addMemberHandler = () => {
                dispatch({
                    type: 'ADD_MEMBER',
                    payload: {
                        member: {
                            id: ++count,
                            name: input.value,
                            team: selectedTeam.value,
                        },
                    },
                })
                input.value = ''
            }

            // function to render the members list on state change
            const renderMembers = () => {
                const { members, filter } = getState()
                membersList.innerHTML = ''
                const listFragment = document.createDocumentFragment()
                const filteredMembers = filter !== 'All'
                    ? members.filter(member => member.team === filter)
                    : members
                filteredMembers.forEach(member => {
                    const memberItem = document.createElement('li')
                    memberItem.innerHTML = `<p style="font-size: 20px; padding: 0px 10px; border-radius: 5px; 
                    background: ${member.team === 'Avengers' ? 'red' : 'green'}">${member.name}</p><span class="delete" data-id=${member.id} onclick="onDelete(event)" style="cursor: pointer;">&#10060;</span>`
                    memberItem.classList.add('member')
                    listFragment.appendChild(memberItem)
                })
                membersList.appendChild(listFragment)
            }

            // delete click event handler
            const onDelete = e => {
                dispatch({
                    type: 'REMOVE_MEMBER',
                    payload: {
                        id: e.target.dataset.id,
                    },
                })
            }

            // subscribing renderAvengers to execute on state **change
            subscribe(renderMembers)

            addMember.addEventListener('click', addMemberHandler)
            input.addEventListener('keypress', enterMemberHandler)
            filters.addEventListener('click', handleFilterMembers)
        </script>
    </body>
</html>

The Heroes web app will now have two new features - to select a team and to filter the list based on the team,

image.png

Earlier we had only one reducer that was used to handle adding and removing members. We have modified that reducer from avengersReducers to membersReducer to make it generic to add a member of different teams.

const filterReducer = (state = 'All', action) => {
                switch (action.type) {
                    case 'FILTER_MEMBERS':
                        return action.payload.filter
                    default:
                        return state
                }
            }

And we have added a new reducer to handle the filter state which will have one of the values from - 'All', 'Avengers', and 'Justice League'.

const combinedReducer = Redux.combineReducers({
                members: membersReducer,
                filter: filterReducer,
            })

const { getState, dispatch, subscribe } = Redux.createStore(combinedReducer)

We use the combineReducer method from Redux to combine our two reducers, which will return the combined reducer. Then we call createStore method with combinedReducer as its argument. The combined state object will be an object with two properties,

  • members: an array of member objects
  • filter: the selected filter among 'All', 'Avengers', and 'Justice League'
const handleFilterMembers = e => {
                if (e.target.classList.contains('filter')) {
                    filters.querySelector('.selected').classList.remove('selected')
                    e.target.classList.add('selected')
                    dispatch({
                        type: 'FILTER_MEMBERS',
                        payload: {
                            filter: e.target.innerText,
                        },
                    })
                }
                renderMembers()
            }

We have also added a click event handler on the list of filters which will update the filter state and then call renderMembers to update the UI.

const renderMembers = () => {
                const { members, filter } = getState()
                membersList.innerHTML = ''
                const listFragment = document.createDocumentFragment()
                const filteredMembers = filter !== 'All'
                    ? members.filter(member => member.team === filter)
                    : members
                filteredMembers.forEach(member => {
                    const memberItem = document.createElement('li')
                    memberItem.innerHTML = `<p style="font-size: 20px; padding: 0px 10px; border-radius: 5px; 
                    background: ${member.team === 'Avengers' ? 'red' : 'green'}">${member.name}</p><span class="delete" data-id=${member.id} onclick="onDelete(event)" style="cursor: pointer;">&#10060;</span>`
                    memberItem.classList.add('member')
                    listFragment.appendChild(memberItem)
                })
                membersList.appendChild(listFragment)
            }

The renderMembers function first fetches the current members list and filter state from the getState method and then accordingly updates the UI and displays the list of heroes as per the selected filter.

Now that we understand when and how to use combineReducers method, let's understand how it works internally.

We know that the combineReducers expects an object as a parameter and returns a function that will serve as the combined reducer. Since the returned function will be a reducer we know it will have two parameters - a state and an action. Also, we can initialize that state with an empty object.

const combineReducers = (reducers) => {
    return (state = {}, action) => {

    }
}

The reducers param is an object whose keys will be the individual state name and values will be the respective individual reducer. So we can use Object.keys(reducers) to iterate over the reducers object. Then use reduce higher-order method of the array to calculate the resultant state. You can learn and understand reduce in detail from here

const combineReducers = (reducers) => {
    return (state = {}, action) => {
        return Object.keys(reducers).reduce((prevState, stateName) => {
            return {
                ...prevState,
                [stateName]: reducers[stateName](state[stateName], action)
            }
        }, state)
    }
}

We initialize the accumulator with the state param of returned combined reducer (empty object) and then the reduce method will iterate over each stateName and will update the resultant state object by executing each reducer and adding its respective returned state.

If the implementation is not completely understandable at the first glance then go through it a couple of times. Add debug points or console logs while using it to understand it better.

Now comment or remove the Redux CDN script tag, and add our implementation of combineReducers at the start of the JS code along with the implementation of createStore from the previous article. I have added it below for your convenience but definitely read the previous article to understand the implementation of createStore

const createStore = (reducer) => {
    let state
    let listeners = []

    const getState = () => state

    const dispatch = (action) => {
        state = reducer(state, action)
        listeners.forEach(listener => listener())
    }

    const subscribe = (listener) => {
        listeners.push(listener)
        return () => {
            listeners = listeners.filter(l => l !== listener)
        }   
    }

    return {
        getState,
        dispatch,
        subscribe
    }
}

const combineReducers = (reducers) => {
    return (state = {}, action) => {
        return Object.keys(reducers).reduce((prevState, stateName) => {
            return {
                ...prevState,
                [stateName]: reducers[stateName](state[stateName], action)
            }
        }, state)
    }
}

const Redux = {
    createStore,
    combineReducers
}

Now our app should continue to work as before without the need to import Redux library code. Test it out thoroughly.

I hope you were able to not only understand the use of combineReducers method but also its implementation. In the next article, I am going to demystify the implementation of the Provider component and the connect higher-order component of the 'react-redux' library. Subscribe to my newsletter to be notified as soon as I publish it.