Boosting React with Redux: Converting Context to Simplify State

Boosting React with Redux: Converting Context to Simplify State

In my previous article, I mentioned the potential upsides of using the Redux library in your React.js apps. I also explained the key concepts for beginners, including stores and a unified reducer for state management. In this article, I'll be explaining the setup of Redux in your React codebase and also the conversion of contexts and reducers to Redux.

What we'll be building

We'll be building a really simple, plain webpage that changes colour when a toggle is clicked. There will also be a textbox, in which the user will enter text. Once the user submits the text, the text on the webpage will change. Although we can simply use useState() to accomplish this, I'll be showing you how this might be implemented using useContext() and useReducer() and then I'll move on to Redux.

Setup

If you're eager to learn Redux, I expect you to be familiar with React.js. Set up a React project by running the following command in a folder you wish to make your project folder:

npx create-react-app .

Install the following library which has a pre-made UI component (a switch) for us:

npm install @radix-ui/react-switch

Once done, delete the unnecessary boilerplate code and add the following code to App.js.

import InputBox from './InputBox'
import * as Switch from '@radix-ui/react-switch'
import './App.css'

function App() {

  return (
   <div className='App'>
     <label className="Label" htmlFor="change-color" style={{ paddingRight: 15 }}>
        Change color
      </label>
      <Switch.Root className="SwitchRoot" id="change-color">
        <Switch.Thumb className="SwitchThumb" />
      </Switch.Root>
      <InputBox inputText={"Replace text here"} />
    </div> 
  )
}


export default App

As you can see, we are importing the readymade switch component and the InputBox component over here. However, we haven't created the InputBox component yet. Let's make the App.css file first and then create the required component.

button {
  all: unset;
}

.SwitchRoot {
  width: 42px;
  height: 25px;
  background-color: blue;
  border-radius: 9999px;
  position: relative;
  box-shadow: 0 2px 10px var(--black-a7);
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.SwitchRoot:focus {
  box-shadow: 0 0 0 2px black;
}

.SwitchThumb {
  display: block;
  width: 21px;
  height: 21px;
  background-color: white;
  border-radius: 9999px;
  box-shadow: 0 2px 2px var(--black-a7);
  transition: transform 100ms;
  transform: translateX(2px);
  will-change: transform;
}
.SwitchThumb[data-state='checked'] {
  transform: translateX(19px);
}

.Label {
  font-size: 15px;
  line-height: 1;
}
export default function InputBox({inputText}) {

  function handleBtnClick() {
    console.log('Clicked')
  }

  return (
    <div>
      <textarea>
        {inputText}
      </textarea>
      <button onClick={handleBtnClick}>Submit</button>
    </div>
  )
}

In the InputBox component we're simply triggering a console.log when the user presses submit.

Using context and reducer

To use context and reducer, add the following code in a new file called context.jsx:

import { createContext, useReducer } from 'react'

function createAction(type, payload) { return {type, payload} }

export const AppContext = createContext({
  isToggleChecked: false,
  setIsToggleChecked: () => {},
  userDisplayText: '',
  setUserDisplayText: () => {}
})

export const AppActionTypes = {
  SET_IS_TOGGLE_CHECKED: 'SET_IS_TOGGLE',
  SET_USER_DISPLAY_TEXT: 'SET_USER_DISPLAY_TEXT'
}

function AppReducer(state, action) {
  const {type, payload} = action

  switch (type) {
    case AppActionTypes.SET_IS_TOGGLE_CHECKED:
      return {
        ...state,
        isToggleChecked: payload
      }
    case AppActionTypes.SET_USER_DISPLAY_TEXT:
      return {
        ...state,
        userDisplayText: payload
      }
    default:
      throw new Error(`Unhandled type of ${type} in AppReducer`)
  }
}

const initialState = {
  isToggleChecked: false,
  userDisplayText: ''
}

export const AppContextProvider = ({ children }) => {
  const [{isToggleChecked, userDisplayText}, dispatch] = useReducer(AppReducer, initialState)

  function setIsToggleChecked(bool) {
    dispatch(createAction(AppActionTypes.SET_IS_TOGGLE_CHECKED, bool))
  }

  function setUserDisplayText(str) {
    dispatch(createAction(AppActionTypes.SET_USER_DISPLAY_TEXT, str))
  }

  const value = {isToggleChecked, setIsToggleChecked, userDisplayText, setUserDisplayText}

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>
}

This is the standard template that I use for implementing context in my React apps. Let me explain the code:

  1. createAction() is used to modularize the creation of actions inside dispatches. This is a very nitpicky optimization, so it's not necessary. It's being used inside the AppContextProvider function when dispatching actions.

  2. AppActionTypes defines the limited number of action types that can be used for dispatching actions. This minimizes errors we make while typing the types out ourselves.

  3. AppReducer is the main reducer for the context and appropriately modifies the state with the payload depending on the action type.

  4. In AppContextProvider we're returning the provider for the context we created. So this function behaves as a provider itself. It also creates a reducer using useReducer(). If you're unaware of reducers or wish to revise, this article I found on Hashnode is a must-read!

How do we use the context.jsx file in our application?

Change the code of index.jsx to the following:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

import {AppContextProvider} from './context'

ReactDOM.createRoot(document.getElementById('root')).render(
    <React.StrictMode>
        <AppContextProvider>
          <App />
        </AppContextProvider>
    </React.StrictMode>
)

We're importing AppContextProvider and using it as a component and wrapping the App component inside it. Let's use the context in App.jsx now:

import InputBox from './InputBox'
import * as Switch from '@radix-ui/react-switch'
import './App.css'

import {useContext} from 'react'

import { AppContext } from './context'

function App() {
  let {isToggleChecked, setIsToggleChecked, userDisplayText} = useContext(AppContext)
  let backgroundColor = isToggleChecked ? 'black' : 'white'
  let textColor = isToggleChecked ? 'white' : 'black'

  return (
   <div className='App' style={{backgroundColor: backgroundColor, color: textColor}}>
     <p>{userDisplayText}</p>
     <label className="Label" htmlFor="change-color" style={{ paddingRight: 15 }}>
        Change color
      </label>
      <Switch.Root className="SwitchRoot" id="change-color" checked={isToggleChecked} onCheckedChange={() => setIsToggleChecked(!isToggleChecked)}>
        <Switch.Thumb className="SwitchThumb" />
      </Switch.Root>
      <InputBox inputText={"Replace text here"} />
    </div> 
  )
}


export default App

We're importing and using the context using useContext(). We're using the value of isToggleChecked to determine the background color and the text color too. For our Switch.Root, we're setting its checked property to equal the isToggleChecked and its onCheckedChange property to trigger a function that toggles the boolean in isToggleChecked. We also need to be able to set the userDisplayText in InputBox.jsx to appropriately change the value in App.jsx. Put the following code in InputBox.jsx:

import {useContext, useRef} from 'react'

import {AppContext} from './context'

export default function InputBox({inputText}) {
  let {setUserDisplayText} = useContext(AppContext)
  let inputRef = useRef(null)

  function handleBtnClick() {
    setUserDisplayText(inputRef.current.value)
  }

  return (
    <div>
      <textarea ref={inputRef}>
        {inputText}
      </textarea>
      <button onClick={handleBtnClick} style={{border: '1px solid black'}}>Submit</button>
    </div>
  )
}

We're using useRef() to reference the value present in the textarea element. Once the user clicks the submit button, we're triggering handleBtnClick which sets the userDisplayText to be inputRef.current.value. This blog by Refine on Hashnode is a great place to get started with useRef() if you've never used it before.

Using Redux patterns

There is a lot of templating that goes into utilizing Redux in our app. First, install redux, react-redux and redux-logger. Redux-logger is a tool that enables us to understand our state changes visually in the console.

npm install redux react-redux redux-logger

Create a folder called store inside of src. Then create the following files in the following structure.

That's a lot of files! You might start to think it's better to just stick to contexts and reducers. I do agree with that, we could've even used useState() for such a simple application! However, I aim to explain to you the core Redux concepts, which are easier done with a smaller application.

In store.js, add the following code:

import { compose, legacy_createStore as createStore, applyMiddleware } from "redux"
import logger from 'redux-logger'

import { rootReducer } from './root-reducer'

const middleWares = [process.env.NODE_ENV !== 'production' && logger].filter(Boolean)

const composedEnhancers = compose(applyMiddleware(...middleWares))

export const store = createStore(rootReducer, undefined, composedEnhancers)

This is almost always how your store.js file will start out. If you are using more Redux libraries this file will look longer and more different. We're importing a bunch of functions from redux and also logger from redux-logger. We're importing rootReducer from our root-reducer.js file, to which we haven't added any code in yet.

middleWares contains the logger only if the process.env.NODE_ENV (the variable stating the environment in which the application is running) is not production. composedEnhancers uses the compose function and the applyMiddleware function to spread out middleWares. store is created using createStore() and takes in rootReducer for its main compiled reducer, undefined for its initial state and composedEnhancers for the store enhancers.

We now need to create the rootReducer in root-reducer.js. Let's do it now.

import { combineReducers } from "redux"

import { appReducer } from './reducer'

export const rootReducer = combineReducers({
    app: appReducer
})

combineReducers() is a function that combines the various reducers in our application (ours only has one since it's so small). The reducer for the app in our state will be appReducer from reducer.js. Let's change the code in reducer.js now:

import { actionTypes } from "./types"

export const initialState = {
  isToggleChecked: false,
  userDisplayText: ''
}

export function appReducer(state = initialState, action = {}) {
    const { type, payload } = action

    switch (type) {
      case actionTypes.SET_IS_TOGGLE_CHECKED:
        return {
          ...state,
          isToggleChecked: payload
        }
      case actionTypes.SET_USER_DISPLAY_TEXT:
        return {
          ...state,
          userDisplayText: payload
        }
      default:
        return state
    }    
}

As you can see, a lot of our code is similar to the code in context.jsx. This is the benefit of using Redux: if you understand the context and reducer pattern this becomes hella easier. We haven't created actionTypes yet, so let's do that in types.js:

export const actionTypes = {
  SET_IS_TOGGLE_CHECKED: 'SET_IS_TOGGLE',
  SET_USER_DISPLAY_TEXT: 'SET_USER_DISPLAY_TEXT'
}

This is exactly the same as the default types in types.js. Now we need action creators, like setIsToggleChecked and setUserDisplayText that we had in context.jsx. Add the following code in action.js:

import { actionTypes } from './types'

function createAction(type, payload) { return {type, payload} }

export function setIsToggleChecked(bool) {
  return createAction(actionTypes.SET_IS_TOGGLE_CHECKED, bool)
}

export function setUserDisplayText(str) {
  return createAction(actionTypes.SET_USER_DISPLAY_TEXT, str)
}

We're again importing actionTypes and creating the handy createAction function. Then instead of dispatching the actions in setIsToggleChecked and setUserDisplayText, we're simply returning the created actions. Dispatching of these actions will be done inside App.jsx and InputBox.jsx.

To be able to use our newly created store, change the code in index.jsx:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

import { store } from './store/store'
import { Provider } from 'react-redux'

ReactDOM.createRoot(document.getElementById('root')).render(
    <React.StrictMode>
    <Provider store={store}>
          <App />
    </Provider>
    </React.StrictMode>
)

We're using the Provider component to expose our store and its values to the entire application. Provider wraps App instead of our context provider.

We can't simply get the values present in our store inside App.jsx and InputBox.jsx. To accomplish this, we need selectors. They select the values in our store and provide them to our components. Let's modify selector.js now:

export const selectIsToggleChecked = (state) => state.app.isToggleChecked

export const selectUserDisplayText = (state) => state.app.userDisplayText

These selectors get the state object and extract the appropriate values out of it to keep our code clean and modular.

Now let's modify App.jsx to use our newly created selectors:

import InputBox from './InputBox'
import * as Switch from '@radix-ui/react-switch'
import './App.css'

import { useSelector, useDispatch } from 'react-redux'
import { selectIsToggleChecked, selectUserDisplayText } from './store/selector'
import { setIsToggleChecked } from './store/action'

function App() {
  const dispatch = useDispatch()

  const isToggleChecked = useSelector(selectIsToggleChecked)
  const userDisplayText = useSelector(selectUserDisplayText)
  let backgroundColor = isToggleChecked ? 'black' : 'white'
  let textColor = isToggleChecked ? 'white' : 'black'

  function onCheckedChangeHandler() {
    dispatch(setIsToggleChecked(!isToggleChecked))
  }

  return (
   <div className='App' style={{backgroundColor: backgroundColor, color: textColor}}>
     <p>{userDisplayText}</p>
     <label className="Label" htmlFor="change-color" style={{ paddingRight: 15 }}>
        Change color
      </label>
      <Switch.Root className="SwitchRoot" id="change-color" checked={isToggleChecked} onCheckedChange={onCheckedChangeHandler}>
        <Switch.Thumb className="SwitchThumb" />
      </Switch.Root>
      <InputBox inputText={"Replace text here"} />
    </div> 
  )
}


export default App

We're creating dispatch which uses react-redux's useDispatch() to be able to dispatch actions such as setIsToggleChecked. useSelector() is enabling us to use our selectors to extract values from the store. If you have any questions in the above code, ask in the comments, and I'll definitely reply!

Now for InputBox.jsx:

import {useRef} from 'react'

import { useDispatch } from 'react-redux'

import { setUserDisplayText } from './store/action'

export default function InputBox({inputText}) {
  const dispatch = useDispatch()

  let inputRef = useRef(null)

  function handleBtnClick() {
    dispatch(setUserDisplayText(inputRef.current.value))
  }

  return (
    <div>
      <textarea ref={inputRef}>
        {inputText}
      </textarea>
      <button onClick={handleBtnClick} style={{border: '1px solid black'}}>Submit</button>
    </div>
  )
}

Again, we're dispatching setUserDisplayText() in our event handler.

We're finally done!

Conclusion

Redux has a lot of setup, but if you have the hang of contexts and reducers, Redux will come to you with practice. I highly recommend comparing the code in context.jsx to the files in the store folder to understand the key differences between these two state management patterns.


Thanks for reading! I'm sure you learnt a lot and have improved as a frontend software engineer. If you'd like to learn more about JavaScript and coding, check out my blog page. To make sure you don't miss out, subscribe to my email newsletter or follow me. I highly appreciate any feedback in the comments :)