Picture of the author
Published on

State Management in React with Redux: An Introduction

Table of Contents

redux dataflow diagram

Image from the redux docs.

State management

In a React app, data is fetched in the parent component and then passed down to child components through props.

This is completely fine in some cases, but things get complicated when there are many layers of components and data/state (and the functions that modify this state) are passed through numerous components to get from origin to destination. This path can be difficult to remember and it leaves many places for errors to be introduced. It will be a complete mess which is called prop drilling like below:

Parent.jsx
import React, { useState } from "react";

function Parent() {
  const [firstName, setFirstName] = useState("firstName");
  const [lastName, setLastName] = useState("LastName");

  return (
    <>
      <div>This is a Parent component</div>
      <br />
      <ChildOne firstName={firstName} lastName={lastName} />
    </>
  );
}

function ChildOne({ firstName, lastName }) {
  return (
    <>
      This is ChildOne Component.
      <br />
      <ChildTwo firstName={firstName} lastName={lastName} />
    </>
  );
}

function ChildTwo({ firstName, lastName }) {
  return (
    <>
      This is ChildTwo Component.
      <br />
      <ChildThree firstName={firstName} lastName={lastName} />
    </>
  );
}

function ChildThree({ firstName, lastName }) {
  return (
    <>
      This is ChildThree component.
      <br />
      <h3> Data from Parent component is as follows:</h3>
      <h4>{firstName}</h4>
      <h4>{lastName}</h4>
    </>
  );
}

export default Parent;

You see that we have to pass the data through so many components. This can get very complicated and very buggy.

React itself does not provide any strong guidelines for how to solve this for shared global application state. As such the React ecosystem has collected numerous approaches and libraries to solve this problem over time.

This can make it confusing when assessing which library or pattern to adopt. The common approach is to outsource this and use whatever works for you and is popular like Redux. That might not be the best way but it can work.

With most state management libraries like Redux, any component can be connected directly to the state.

This isn’t to say that data is no longer passed down from parent components to child components via props. Rather, this path can now be direct, no passing props down from parent to great-great-great-great-great-great-grandchild.

Uses of a state management library

  • Ability to read stored state from anywhere in the component tree: This is the most basic function of a state management library. It allows developers to persist their state in memory, and avoid the issues prop drilling has at scale.
  • Ability to write to the stored state: A library should provide an intuitive API for both reading and writing data to the store.
  • Provide mechanisms to optimize rendering: The model of UI as a function of the state is both incredibly simple and productive. However, the process of reconciliation when that state changes is expensive at scale. And often leads to poor runtime performance for large apps(unnecessary rerenders). With this model, a global state management library needs to detect when to re-render after its state gets updated and only re-render what is necessary. Optimizing this process is one of the biggest challenges a state management library needs to solve. Redux has a manual optimization that involves subscribing to a piece of stored state through a selector function ( useSelector(state => state.foo) ). Components that read state through a selector will only re-render when that specific piece of state updates.
  • Provide mechanisms to optimize memory usage: For very large frontend applications, not managing memory properly can silently lead to issues at scale.
  • Serialization of data: It can be useful to have a fully serializable state so you can save and restore the application state from storage somewhere.

Redux

Redux is a library to manage global state, we use redux in libraries and frameworks like react, angular,…

I highly recommend watching the above video before getting started ☝️.

A Predictable State Container for JS Apps:

  • Predictable: Redux helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.
  • Centralised: Centralizing your application's state and logic enables powerful capabilities like undo/redo, state persistence, and much more.
  • Debuggable: The Redux DevTools make it easy to trace when, where, why, and how your application's state changed. Redux's architecture lets you log changes, use "live code editing combined with a time-travelling debugger", and even send complete error reports to a server.
  • Flexible: Redux works with any UI layer, and has a large ecosystem of add-ons to fit your needs.

You can use Redux together with React, or with any other view library. It is tiny (2kB, including dependencies), but has a large ecosystem of addons available.

Terminology

Redux

Image from the redux docs.

There are some important Redux terms that you'll need to be familiar with before we continue:

Actions

An action is a plain JavaScript object that has a type field. You can think of an action as an event that describes something that happened in the application.

The type field should be a string that gives this action a descriptive name, like "todos/todoAdded". We usually write that type string like "domain/eventName", where the first part is the feature or category that this action belongs to, and the second part is the specific thing that happened.

An action object can have other fields with additional information about what happened. By convention, we put that information in a field called payload.

A typical action object might look like this:

const addTodoAction = {
  type: 'todos/todoAdded',
  payload: 'Buy milk'
}
Action Creators

An action creator is a function that creates and returns an action object. We typically use these so we don't have to write the action object by hand every time:

const addTodo = text => {
  return {
    type: 'todos/todoAdded',
    payload: text
  }
}

Reducers

reducer is a function that receives the current state and an action object, decides how to update the state if necessary, and returns the new state: (state, action) => newStateYou can think of a reducer as an event listener which handles events based on the received action (event) type.

Reducers must always follow some specific rules:

  • They should only calculate the new state value based on the state and action arguments
  • They are not allowed to modify the existing state. Instead, they must make immutable updates, by copying the existing state and making changes to the copied values.
  • They must not do any asynchronous logic, calculate random values, or cause other "side effects"

The logic inside reducer functions typically follows the same series of steps:

  1. Check to see if the reducer cares about this action. If so, make a copy of the state, update the copy with new values, and return it
  2. Otherwise, return the existing state unchanged

Here's a small example of a reducer, showing the steps that each reducer should follow:

const initialState = { value: 0 }

function counterReducer(state = initialState, action) {

  // Check to see if the reducer cares about this action
  if (action.type === 'counter/increment') {
    // If so, make a copy of `state`
    return {
      ...state,
      // and update the copy with the new value
      value: state.value + 1
    }
  }

  // otherwise return the existing state unchanged
  return state
}

Reducers can use any kind of logic inside to decide what the new state should be: if/elseswitch, loops, and so on.

Store

Contains the state of your entire application.

import { configureStore } from '@reduxjs/toolkit'

const store = configureStore({ reducer: counterReducer })

console.log(store.getState())
// {value: 0}

Other

  • Dispatch: The Redux store has a method called dispatchThe only way to update the state is to call store.dispatch() and pass in an action object.

    The store will run its reducer function and save the new state value inside, and we can call getState() to retrieve the updated value:

    store.dispatch({ type: 'counter/increment' })
    
    console.log(store.getState())
    // {value: 1}
    

    You can think of dispatching actions as "triggering an event" in the application. Something happened, and we want the store to know about it. Reducers act like event listeners, and when they hear an action they are interested in, they update the state in response.

    We typically call action creators to dispatch the right action:

    const increment = () => {
      return {
        type: 'counter/increment'
      }
    }
    
    store.dispatch(increment())
    
    console.log(store.getState())
    // {value: 2}
    
  • Selectors: are functions that know how to extract specific pieces of information from a store state value. As an application grows bigger, this can help avoid repeating logic as different parts of the app need to read the same data:

    const selectCounterValue = state => state.value
    
    const currentValue = selectCounterValue(store.getState())
    
    console.log(currentValue)
    // 2
    
  • Middleware: A Redux middleware provides an extension point between dispatching an action and the moment it reaches the reducer. Redux-Thunk is a middleware that helps us write async logic that interacts with the store.

Redux application data flow

Earlier, we talked about "one-way data flow", which describes this sequence of steps to update the app:

  1. State describes the condition of the app at a specific point in time
  2. The UI is rendered based on that state
  3. When something happens (such as a user clicking a button), the state is updated based on what occurred
  4. The UI re-renders based on the new state

For Redux specifically, we can break these steps into more detail:

  • Initial setup:
    • A Redux store is created using a root reducer function
    • The store calls the root reducer once and saves the return value as its initial state
    • When the UI is first rendered, UI components access the current state of the Redux store, and use that data to decide what to render. They also subscribe to any future store updates so they can know if the state has changed.
  • Updates:
    • Something happens in the app, such as a user clicking a button
    • The app code dispatches an action to the Redux store, like dispatch({type: 'counter/increment'})
    • The store runs the reducer function again with the previous state and the current action and saves the return value as the new state
    • The store notifies all parts of the UI that are subscribed that the store has been updated
    • Each UI component that needs data from the store checks to see if the parts of the state they need have changed.
    • Each component that sees its data has changed forces a re-render with the new data, so it can update what's shown on the screen
redux dataflow diagram

Image from the redux docs.

Basic example

The whole global state of your app is stored in an object tree inside a single store. The only way to change the state tree is to create an action, an object describing what happened, and dispatch it to the store. To specify how state gets updated in response to an action, you write pure reducer functions that calculate a new state based on the old state and the action:

import { createStore } from 'redux'

// ------------------------------------------------------------------------------------
/**
 * This is a reducer - a function that takes a current state value and an
 * action object describing "what happened", and returns a new state value.
 * A reducer's function signature is: (state, action) => newState
 *
 * The Redux state should contain only plain JS objects, arrays, and primitives.
 * The root state value is usually an object. It's important that you should
 * not mutate the state object, but return a new object if the state changes.
 *
 * You can use any conditional logic you want in a reducer. In this example,
 * we use a switch statement, but it's not required.
 */
function counterReducer(state = { value: 0 }, action) {
  switch (action.type) {
    case 'counter/incremented':
      return { value: state.value + 1 }
    case 'counter/decremented':
      return { value: state.value - 1 }
    default:
      return state
  }
}

// ------------------------------------------------------------------------------------
// Create a Redux store holding the state of your app.
// Its API is { subscribe, dispatch, getState }.
let store = createStore(counterReducer)

// ------------------------------------------------------------------------------------
// You can use subscribe() to update the UI in response to state changes.
// Normally you'd use a view binding library (e.g. React Redux) rather than subscribe() directly.
// There may be additional use cases where it's helpful to subscribe as well.
store.subscribe(() => console.log(store.getState()))

// ------------------------------------------------------------------------------------
// The only way to mutate the internal state is to dispatch an action.
// The actions can be serialized, logged or stored and later replayed.
store.dispatch({ type: 'counter/incremented' })
// {value: 1}
store.dispatch({ type: 'counter/incremented' })
// {value: 2}
store.dispatch({ type: 'counter/decremented' })
// {value: 1}

As shown above:

Instead of mutating the state directly, you specify the mutations you want to happen with plain objects called actions. Then you write a special function called a reducer to decide how every action transforms the entire application's state.

In a typical Redux app, there is just a single store with a single root reducing function. As your app grows, you split the root reducer into smaller reducers independently operating on the different parts of the state tree. This is exactly like how there is just one root component in a React app, but it is composed out of many small components.

This architecture might seem like a lot for a counter app, but the beauty of this pattern is how well it scales to large and complex apps. It also enables very powerful developer tools, because it is possible to trace every mutation to the action that caused it. You can record user sessions and reproduce them just by replaying every action.

Conclusion

Feel free to get started with React and Redux using the following:

Sources and more reading: