Uncategorized

ReactJS – Reducing State with useReducer

Author: Jonathon Doetsch (jonathon.doetsch@gmail.com)

Introduction

Introduced in React 16.8, the useState hook quickly became the first choice option for many developers needing to create a ‘stateful’ variable that can trigger DOM changes. However, useState variable usage can quickly sprawl, and developers will find themselves debugging components with dozens of useState variables, which can be updated from anywhere within that component – or multiple components if shared via props or the useContext hook. So how can React developers avoid creating such a brittle UI state? Thankfully, React 16.8 also included a hook designed to help developers reduce and secure UI state – the useReducer hook. In this article, we’ll dive into the useReducer hook and close by showing this hook in use in a demo application.

Toolkit Builder Demo

Reducing State

The official React documentation defines useReducer as a React hook that allows you to add a reducer to your component. A reducer is a design pattern that is used to manage the state of a UI. It involves breaking down the application state into smaller, more manageable pieces, and defining a set of functions called reducers, which are responsible for updating those pieces of state in response to user actions or other events. While there may be some use cases that necessitate the use of third-party reducer tools (e.g. Redux), the useReducer hook is an excellent state reduction option for most React projects.

const [pageState, pageDispatch] = useReducer(pageStateReducer, defaultPageState)

The third parameter for the useReducer hook is an optional initializer function. Let’s take a look at the defaultPageState parameter. This parameter should be an object representing the full UI state that you want to be managed by the reducer. Here is a simple example:

const defaultPageState = {
    statefulVar1: 'stateful value 1',
    statefulVar2: 'stateful value 2'
}

Once the useReducer hook is called, this initial state parameter will be used to create and return the pageState variable. This variable will be a stateful object containing all of the properties included in defaultPageState. However, unlike the useState hook, which would also return a setPageState variable, useReducer returns a variable called pageDispatch, which will dispatch various actions to be handled by the reducer. We’ll take a closer look at this dispatch function later, but first, we should review the pageStateReducer parameter.

const pageStateReducer = (state, action) => {
    switch (action.type) {
        case 'Action 1': return {...state, statefulVar1: action.value}
        case 'Action 2': return {...state, statefulVar2: action.value}
        default: return {...state}
    }
}

This function is defined with two parameters: state and action. The state parameter represents the existing state that we can access in our page component as pageState, and this parameter is automatically passed to the reducer when we dispatch actions. The action parameter, usually including action.type and action.value, will be passed by our page component using pageDispatch. The reducer function contains a switch block that will update different stateful variables based on action.type and action.value.

const onClick = (event) => {
    event.preventDefault
    pageDispatch({
        type: 'Action 1',
        value: 'Updated stateful value'
    })
}

The pageDispatch function only has one parameter: an object representing the action to be dispatched to the reducer. You can define this object however you want – type and value are the standard variables to include, but they are not mandatory. The onClick function will execute the following case in our reducer:

case 'Action 1': 
    return {
        ...state,
        statefulVar1: action.value
    }

The return value represents the state updates that will be performed in pageState. We are using the spread operator (…state) to retrieve the existing state, before selectively updating statefulVar1. The new value, “Updated stateful value”, is available in our page component. Similar to useState, this update will trigger re-render for any components that use pageState in their rendering process.

Demo Application – Toolkit Builder

Now that we’ve reviewed the useReducer hook, let’s move on to the demo! You can find a link to the Github repository for this demo at the bottom of the article.

Toolkit Builder – Home Page

The Toolkit Builder is a demo React app containing three questionnaires (Backend, Frontend, and Mobile) that will recommend the best tech stack for each discipline. I’ve written the Backend questionnaire with useState variables, while Frontend and Mobile each implement the useReducer pattern. This article will compare and contrast the Backend and Frontend components to demonstrate how useReducer can improve React applications.

Demo Application – State Initialization

Initialization of state variables is the first step in building our UI, as seen on lines 58-76 of App.js below:

const [backendState, setBackendState] = useState(defaultBackendState)
const [frontendState, frontendDispatch] = useReducer(frontendStateReducer, defaultFrontendState)
const [mobileState, mobileDispatch] = useReducer(mobileStateReducer, defaultMobileState)

const initializeResults = () => {
    setBackendState(defaultBackendState)
    frontendDispatch({
        type: FRONTEND_ACTIONS.INITIALIZE,
        value: 'Initialize Results'
    })
    mobileDispatch({
        type: MOBILE_ACTIONS.INITIALIZE,
        value: 'Initialize Results'
    })
}

useEffect(() => {
    initializeResults()
}, [])

As discussed in the section on Reducing State, the useReducer pattern does not allow for frontendState to be updated directly by a setState function (i.e. setBackendState), instead returning the frontendDispatch function. This function will dispatch actions to frontendStateReducer, where all state updates will be performed.

Demo Application – State Propagation

By initializing these state variables at the top level of the application, we ensure that the UI state of each component will persist throughout the application. These variables will now be propagated into AppContext (defined on lines 36-54 of App.js) using a default context provider:

return (
    <AppContext.Provider value={{
        /* additional context vars */
        backendState: backendState,
        setBackendState: setBackendState,
        frontendState: frontendState,
        frontendDispatch: frontendDispatch,
        mobileState: mobileState,
        mobileDispatch: mobileDispatch,
	/* additional context vars */
    }}>
        {/* full render statement on lines 78-112 */}
    </AppContext.Provider>
)

The values provided by AppContext will be available to retrieve (with the useContext hook) by any of the components rendered within the Provider.

Demo Application – Generate Results

With the UI state initialized, questionnaire components can begin performing updates. We will begin with the generateResults function in BackendQuestionnaire.js (lines 57-185). I’ve removed the questionnaire logic and some setBackendState implementations from this code snippet for brevity.

const generateResults = (event) => { 
    event.preventDefault()
    if (/* .NET F# responses */) {
        setBackendState({
            ...backendState, 
            pageContent: 'results',
            resultStatus: {
                frameworkDescription: dotNetDescription,
                frameworkIcon: <DotNetIcon/>,
                frameworkTitle: '.NET',
                languageDescription: fSharpDescription,
                languageIcon: <FSharpIcon/>,
                languageTitle: 'F#',
                testingDescription: nUnitDescription,
                testingIcon: <NUnitIcon/>,
                testingTitle: 'NUnit'
            }
        })
    } else if (/* .NET C# responses */) {
        setBackendState({
            ...backendState, 
            pageContent: 'results',
            resultStatus: {
                frameworkDescription: dotNetDescription,
                frameworkIcon: <DotNetIcon/>,
                frameworkTitle: '.NET',
                languageDescription: cSharpDescription,
                languageIcon: <CSharpIcon/>,
                languageTitle: 'C#',
                testingDescription: nUnitDescription,
                testingIcon: <NUnitIcon/>,
                testingTitle: 'NUnit'
            }
        })
    } else if (/* Spring Java responses */) {
        setBackendState(/* Spring Java results */)
    } else if (/* Spring Kotlin responses */) {
        setBackendState(/* Spring Kotlin results */)
    } else if (/* Ktor Kotlin responses */) {
        setBackendState(/* Ktor Kotlin results)
    } else (/* default to Django Python */) {
        setBackendState(/* Django Python results */)
    }
}

The length of this function demonstrates one of the drawbacks of the useState pattern. Without a reducer to control UI state updates, the Backend component is responsible for both logical decisions and performing updates. Implementing this pattern will increase the size of your components, which also increases debugging and maintenance time. In contrast, the generateResults function implemented in FrontendQuestionnaire.js (lines 32-38) is very simple:

const generateResults = (event) => {
    event.preventDefault()
    frontendDispatch({
       type: FRONTEND_ACTIONS.GENERATE_RESULTS,
       value: 'Generate Results'
    })
}

Implementing the useReducer pattern allows us to separate our UI actions (like generateResults) from our state updates. We simply call frontendDispatch with the action that needs to be performed, and frontendStateReducer (lines 114-165 of FrontendQuestionnaireState.js) will handle the rest. The following switch case will be executed when action.type is GENERATE_RESULTS:

case FRONTEND_ACTIONS.GENERATE_RESULTS:
    return {
        ...state,
        pageContent: 'results',
        resultStatus: calculateResultStatus(state)
    }

While the length of this switch case is reduced by the calculateResultStatus function (lines 60-112), the readability of the Frontend component is greatly improved. Additionally, frontendStateReducer provides a secure UI state by restricting state updates to a set of actions. In contrast, there are no restrictions on the updates performed by setBackendState. While this may seem inconsequential, a component designed in this manner can become increasingly difficult to maintain over time, as new developers are not required to adhere to good useState practices. The useReducer pattern ensures that the UI state will remain secure as your team expands or your application scales up.

Toolkit Builder – Generate Results

Demo Application – Reset Page

Once the user has submitted a questionnaire, they will have the option to click ‘Try Again’ and submit new answers. The selected questionnaire will then call the resetPage function. We will start with the resetPage implementation in BackendQuestionnaire.js (lines 193-210):

const resetPage = (event) => {
    event.preventDefault()
    setBackendState({
        ...backendState,
        pageContent: 'quiz',
        resultStatus: {
            frameworkDescription: '',
            frameworkIcon: <></>,
            frameworkTitle: '',
            languageDescription: '',
            languageIcon: <></>,
            languageTitle: '',
            testingDescription: '',
            testingIcon: <></>,
            testingTitle: ''
        }
    })
}

In order to preserve user responses, setBackendState will use the spread operator to selectively update pageContent and resultStatus. While this function is fairly short, the resetPage implementation in FrontendQuestionnaire.js (lines 40-46) is once again much simpler.

const resetPage = (event) => {
    event.preventDefault()
    frontendDispatch({
       type: FRONTEND_ACTIONS.RESET_PAGE,
       value: 'Reset Page'
    })
}

Remember, FrontendQuestionnaire is not responsible for state updates. This function will use frontendDispatch to trigger a RESET_PAGE action in frontendStateReducer, where the following switch case will be executed:

case FRONTEND_ACTIONS.RESET_PAGE:
    return {
        ...state,
        pageContent: 'quiz',
        resultStatus: {
            frameworkDescription: '',
            frameworkIcon: <></>,
            frameworkTitle: '',
            languageDescription: '',
            languageIcon: <></>,
            languageTitle: '',
            testingDescription: '',
            testingIcon: <></>,
            testingTitle: ''
        }
    }

While the RESET_PAGE updates being performed by frontendStateReducer are identical to the updates performed by Backend implementation of resetPage, the useReducer pattern ensures that these updates are not performed directly by the component. This division of responsibilities between reducers and components will produce readable code and a reliable UI state.

Toolkit Builder – Reset Page

Conclusion

React HookProsCons
useReducerLow maintenance cost
Provides reliable state
Easy to debug issues
Easy to scale up
High initial setup cost
Restricted update access
Moderately complex
useStateLow initial setup cost
Unrestricted updates
Simple, easy to use
High maintenance cost
Provides brittle state
Hard to debug at scale
Hard to scale up
Pros and Cons: useReducer vs useState

While the useState hook can be a good solution for managing small UI states, larger UI states that require many variables to be tracked can become very difficult to read and debug if implemented with this hook. In contrast, the useReducer hook requires a moderate amount of setup, making it a less attractive option for small UI states, but implementing the useReducer pattern for larger UI states is clean and efficient. Whether using useState or useReducer, be sure to store your stateful variables with the combination of the createContext and useContext hooks, which will propagate your state throughout your application.

References

Author

Cahlen Humphreys