Skip to content

a

Flux-architecture and Redux

So far, we have followed the state management conventions recommended by React. We have placed the state and the functions for handling it in a higher single component, and then passed the state into the various components that use it. Quite often most of the app state and state altering functions reside directly in the root component. We then pass the state and its handlers to other components via props. This works up to a certain point, but when applications grow larger, state management becomes challenging.

Flux-architecture

A few years back Facebook developed the Flux architecture to make state management of React apps easier. In Flux, the state is separated from the React components and into its own stores. State in the store is not changed directly, but with different actions.

When an action changes the state of the store, the views are re-rendered:

diagram - action->dispatcher->store->view

For example, if you push a button that triggers the need to change the state, that change will be made with an action. This causes re-rendering the view again:

same diagram as above but with action looping back

Flux provides a standardized way to store and modify an application's state.

Redux

Facebook has an implementation for Flux, but we will be using the Redux library. It follows the same principles but is simpler to use. Facebook also uses Redux now instead of their original Flux.

We will get to know Redux by implementing a counter application yet again:

browser counter application

Start with a new Vite application and then install redux with the command

npm i redux

Redux's Store and Actions

Like Flux, the state in Redux is housed in a store. You can think of the store as like a mini-database that redux uses to house information.

Unlike Flux, Redux stores this state in a single JavaScript object. Because our application only needs the value of the counter, we will save it straight to the store. If the state were more complex, the different values would be saved as separate properties in the object.

The state of the store is changed with actions. Actions are objects, which have at least a field determining the type of the action. Our application will need for example the following action:

{
  type: 'INCREMENT'
}

If there is data involved with the action, other fields can be declared as needed. However, our counting app is so simple that the actions are fine with just the type field.

Reducers

The impact of the action to the state of the application is defined using a reducer. In practice, a reducer is a function that is given the current state and an action as parameters. It returns a new state.

Let's now define a reducer for our application:

const counterReducer = (state, action) => {
  if (action.type === 'INCREMENT') {
    return state + 1
  } else if (action.type === 'DECREMENT') {
    return state - 1
  } else if (action.type === 'ZERO') {
    return 0
  }

  return state
}

The first parameter is the state in the store. The *reducer returns a new state based on the action type. So, here when the type of Action is *INCREMENT, the state gets the old value plus one. If the type of Action is ZERO the new value of state is zero.

Let's change the code a bit. We have used if-else statements to build our reducer; counterReducer responds to an action and changes the state. However, the switch statement is the most common approach to writing a reducer.

Let's also define a default value of 0 for the parameter state. Now the reducer works even if the state has not been initialized.

const counterReducer = (state = 0, action) => {  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'ZERO':
      return 0
    default: // if none of the above matches, code comes here
      return state
  }
}

While counterReducer is a function, we are never supposed to call any reducer directly from our code. Instead, we pass the reducer into the older createStore function (for now), which will handle calling the function:

import { createStore } from 'redux'
const counterReducer = (state = 0, action) => {
  // ...
}

const store = createStore(counterReducer)

The store now uses the reducer to handle actions, which are dispatched or 'sent' to the store with its dispatch method.

store.dispatch({ type: 'INCREMENT' })

You can find out the state of store using the method getState.

For example, the following code:

const store = createStore(counterReducer)
console.log(store.getState())
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
console.log(store.getState())
store.dispatch({ type: 'ZERO' })
store.dispatch({ type: 'DECREMENT' })
console.log(store.getState())

would print the following to the console

0
3
-1

At first, the state of store is 0. After three INCREMENT actions, store's state is 3. In the end, after ZERO and DECREMENT actions, the store's state is -1.

In addition to dispatch and getState, store also has the subscribe method, which is used to create callback functions the store calls whenever an action is dispatched to it.

If, for example, we would add the following function to subscribe, every change in the store would be printed to the console.

store.subscribe(() => {
  const storeNow = store.getState()
  console.log('breaking news! ', storeNow)
})

so the code

const store = createStore(counterReducer)

store.subscribe(() => {
  const storeNow = store.getState()
  console.log('breaking news! ', storeNow)
})

store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'ZERO' })
store.dispatch({ type: 'DECREMENT' })

would cause the following to be printed

breaking news! 1
breaking news! 2
breaking news! 3
breaking news! 0
breaking news! -1

Let's now replace our hello world content with buttons and some visible HTML. The entire code is below. All of it has been written in main.jsx, so store is directly available everywhere 👀. Later on we will focus on properly structuring our React/Redux code.

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

import { createStore } from 'redux'

const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'ZERO':
      return 0
    default:
      return state
  }
}

const store = createStore(counterReducer)

const App = () => {
  return (
    <div>
      <div>
        {store.getState()}      </div>
      <button 
        onClick={e => store.dispatch({ type: 'INCREMENT' })}      >
        plus
      </button>
      <button
        onClick={e => store.dispatch({ type: 'DECREMENT' })}      >
        minus
      </button>
      <button 
        onClick={e => store.dispatch({ type: 'ZERO' })}      >
        zero
      </button>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById('root'))

const renderApp = () => {  root.render(<App />)}renderApp()store.subscribe(renderApp)

There are a few notable things in the code. App renders the value of the counter by asking for it from the store with the method store.getState(). The buttons' onClick handlers dispatch the right action object to the store.

When store's state changes, React cannot automatically re-render the application by itself. Thus we have registered a function renderApp with store.subscribe. Now, renderApp will re-render the entire app anytime store changes. Remember that we have to also directly call renderApp(). Otherwise, we would have only defined renderApp and so the first rendering of the app would never happen.

A notice about the use of createStore

You may have noticed that in WebStorm, createStore has a strikethrough. If you move the mouse over the name, an explanation will appear

vscode error showing createStore deprecated and to use configureStore

Here's an official explanation:

*We recommend using the configureStore method of the @reduxjs/toolkit package, which replaces createStore.*

Redux Toolkit is our recommended approach for writing Redux logic today, including store setup, reducers, data fetching, and more.

For more details, please read this Redux docs page: https://redux.js.org/introduction/why-rtk-is-redux-today

configureStore from Redux Toolkit is an improved version of createStore that simplifies setup and helps avoid common bugs.

You should not be using the redux core package by itself today, except for learning purposes. The createStore method from the core redux package will not be removed, but we encourage all users to migrate to using Redux Toolkit for all Redux code.

So, since Redux recommends configureStore over createStore, we will start using configureStore in the next section, but we will continue using createStore here as we are still introducing redux concepts.

An Aside: createStore is defined as deprecated. Functions/features that are deprecated are sometimes removed in a future version of a library. The explanation above and the discussion of this one reveal that createStore will not be removed, and it has been given the status deprecated, perhaps to nudge people to use configureStore instead.

You can see the full code for the calculator on GitHub in the main branch.

Redux-tasks

We aim to modify our task application to use Redux for state management. However, let's first cover a few key concepts through a simplified task application.

The first version of our application is the following

const taskReducer = (state = [], action) => {
  if (action.type === 'NEW_TASK') {
    state.push(action.payload)
    return state
  }

  return state
}

const store = createStore(taskReducer)

store.dispatch({
  type: 'NEW_TASK',
  payload: {
    content: 'learn more about how the app state is in redux store',
    important: true,
    id: 1
  }
})

store.dispatch({
  type: 'NEW_TASK',
  payload: {
    content: 'understand more fully how state changes are made with actions',
    important: false,
    id: 2
  }
})

const App = () => {
  return(
    <div>
      <ul>
        {store.getState().map(task=>
          <li key={task.id}>
            {task.content} <strong>{task.important ? '- important' : ''}</strong>
          </li>
        )}
        </ul>
    </div>
  )
}

So far the application does not have the functionality for adding new tasks, although it is possible to do so by dispatching NEW_TASK actions.

Notice how the actions have a type and a payload, the latter containing the task to be added:

{
  type: 'NEW_TASK',
  payload: {
    content: 'understand more fully how state changes are made with actions',
    important: false,
    id: 2
  }
}

The general convention is that actions have exactly two fields:

  1. type referring to the type of Action (ie NEW_TASK)
  2. payload containing the data to include with the Action.

Pure functions, immutable

If we remember the goal of a reducer, then our initial version of taskReducer is straightforward:

const taskReducer = (state = [], action) => {
  if (action.type === 'NEW_TASK') {
    state.push(action.payload)
    return state
  }

  return state
}

The state here is an Array. NEW_TASK actions cause a new task to be added to the state with the push method.

While the application seems to work, our taskReducer implementation is unacceptable. Unfortunately, taskReducer breaks a basic assumption that Redux reducers must be pure functions.

Pure functions must:

  • not cause any side effects
  • always return the same response when called with the same parameters

We added a new task to the state with the method state.push(action.payload). Calling state.push *changes our state object which is a side-effect*. This is not allowed. The problem is easily solved by using the concat method. Calling state.concat creates a new array that contains all the elements of the old array along with the new element:

const taskReducer = (state = [], action) => {
  if (action.type === 'NEW_TASK') {
    return state.concat(action.payload)  }

  return state
}

A reducer state must be composed of immutable objects. If there is a change in the state, the old object is not changed, but it is replaced with a new, changed, object. This is exactly what we did with our revised taskReducer: the old state array is replaced with the new one.

Let's expand our reducer so that it can handle the change of a task's importance:

{
  type: 'TOGGLE_IMPORTANCE',
  payload: {
    id: 2
  }
}

TDD with Redux setup

Since we do not have any code which uses this functionality yet, let's expand the reducer via Test-Driven Development (TDD). Let's start by creating a test for handling the action NEW_TASK.

In short, TDD follows this three-step process:

  1. Make a test that causes the existing code to fail (red)
  2. Change the code so that it passes all the tests again (green)
  3. Refactor the code while ensuring the tests still pass (refactor)

To follow TDD, we'll need to configure the Jest testing library for the project. First, install the following dependencies:

npm i -D jest @babel/preset-env @babel/preset-react eslint-plugin-jest 

Next we'll create the file .babelrc, with the following content:

{
    "presets": [
        "@babel/preset-env",
        ["@babel/preset-react", { "runtime": "automatic" }]
    ]
}

Let us expand package.json with a script for running the tests:

{
    // ...
    "scripts": {
        "dev": "vite",
        "build": "vite build",
        "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
        "preview": "vite preview",
        "test": "jest"    },
    // ...
}

And finally, .eslintrc.cjs needs to be altered as follows:

module.exports = {
    root: true,
    env: { 
        browser: true,
        es2020: true,
        "jest/globals": true    },
    // ...
}

Testing and Deep Freeze

To make testing easier, we'll first move the reducer's code to its own module to file src/reducers/taskReducer.js. We'll also add the library deep-freeze, which can help ensure that our reducer has been correctly defined as an immutable function. Let's install the library as a development dependency

npm i -D deep-freeze

The test, which we define in file src/reducers/taskReducer.test.js, has the following content:

import taskReducer from './taskReducer'
import deepFreeze from 'deep-freeze'

describe('taskReducer', () => {
  test('returns new state with action NEW_TASK', () => {
    const state = []
    const action = {
      type: 'NEW_TASK',
      payload: {
        content: 'learn more about how the app state is in redux store',
        important: true,
        id: 1
      }
    }

    deepFreeze(state)    const newState = taskReducer(state, action)

    expect(newState).toHaveLength(1)
    expect(newState).toContainEqual(action.payload)
  })
})

The deepFreeze(state) command ensures that the reducer does not change the state of the store given to it as a parameter. If the reducer uses the push command to manipulate the state, the test will not pass

terminal showing test failure and error about not using array.push

Our first redux test - toggling importance

Now that we setup our first step, let's start with TDD. We have yet to implement the functionality for toggling the importance of a task. Let's then create a test for the TOGGLE_IMPORTANCE action:

test('returns new state with action TOGGLE_IMPORTANCE', () => {
  const state = [
    {
      content: 'learn more about how the app state is in redux store',
      important: true,
      id: 1
    },
    {
      content: 'understand more fully how state changes are made with actions',
      important: false,
      id: 2
    }]

  const action = {
    type: 'TOGGLE_IMPORTANCE',
    payload: {
      id: 2
    }
  }

  deepFreeze(state)
  const newState = taskReducer(state, action)

  expect(newState).toHaveLength(2)

  expect(newState).toContainEqual(state[0])

  expect(newState).toContainEqual({
    content: 'understand more fully how state changes are made with actions',
    important: true,
    id: 2
  })
})

So the following action

{
  type: 'TOGGLE_IMPORTANCE',
  payload: {
    id: 2
  }
}

has to change the importance of the task with the id 2.

If you were to run the test, it would fail, because we have yet to implement anything related to TOGGLE_IMPORTANCE. With our failing test, we can now work on the reducer. Below is our expanded reducer:

const taskReducer = (state = [], action) => {
  switch(action.type) {
    case 'NEW_TASK':
      return state.concat(action.payload)
    case 'TOGGLE_IMPORTANCE': {
      const id = action.payload.id
      const taskToChange = state.find(t => t.id === id)
      const changedTask = { 
        ...taskToChange, 
        important: !taskToChange.important 
      }
      return state.map(task =>
        task.id !== id ? task : changedTask 
      )
     }
    default:
      return state
  }
}

We create changedTask, a copy of the task but with the important field flipped, utilizing the code we implemented in part 2. We then replace the state with a new object containing the unchanged tasks along with our changedTask.

Let's recap what goes on in the code. First, we search for a specific task object, the importance of which we want to change:

const taskToChange = state.find(t => t.id === id)

then we create a new object, which is a copy of the original task, only the value of the important field has been flipped to the opposite of what it was:

const changedTask = { 
  ...taskToChange, 
  important: !taskToChange.important 
}

We create a new state by taking all of the tasks from the old state (via map) except for the desired task, which we replace with its slightly altered copy:

state.map(task =>
  task.id !== id ? task : changedTask 
)

Our reducer then returns that new array of tasks.

Array spread syntax

Because we now have a reducer that passes our tests, we can follow TDD and move to refactor the code.

Currently in NEW_TASK, the code creates a copy of state via the Array's concat function. Let's take a look at how we can achieve the same by using JavaScript's spread operator syntax:

const taskReducer = (state = [], action) => {
  switch(action.type) {
    case 'NEW_TASK':
      return [...state, action.payload]    case 'TOGGLE_IMPORTANCE':
      // ...
    default:
    return state
  }
}

Here's how the spread syntax works. If we declare:

const my_physics_midterm_scores = [35, 34, 31]

...my_midterm_scores breaks the array up into individual elements, which can be placed in another array.

[...my_physics_midterm_scores, 23, 13]

and the result is an array [35, 34, 31, 23, 13].

If we would have placed the array into another array without the spread operator

[numbers, 34, 35]

the result would have been [ [35, 34, 31] , 23, 13].

When we take elements from an array by destructuring, a similar-looking syntax is used to gather the rest of the elements:

const my_physics_midterm_scores = [35, 34, 31, 23, 13, 7]

const [first, second, ...rest] = my_physics_midterm_scores

console.log(first)     // prints 35
console.log(second)   // prints 34
console.log(rest)     // prints [31, 23, 13, 7]

Uncontrolled form

Let's add the functionality for adding new tasks and changing their importance:

const generateId = () => Number((Math.random() * 1000000).toFixed(0))
const App = () => {
  const addTask = (event) => {    event.preventDefault()    const content = event.target.task.value    event.target.task.value = ''    store.dispatch({      type: 'NEW_TASK',      payload: {        content,        important: false,        id: generateId()      }    })  }
  const toggleImportance = (id) => {    store.dispatch({      type: 'TOGGLE_IMPORTANCE',      payload: { id }    })  }
  return (
    <div>
      <form onSubmit={addTask}>        <input name="task" />         <button type="submit">add</button>      </form>      <ul>
        {store.getState().map(task =>
          <li
            key={task.id} 
            onClick={() => toggleImportance(task.id)}          >
            {task.content} <strong>{task.important ? '- important' : ''}</strong>
          </li>
        )}
      </ul>
    </div>
  )
}

The implementation of both actions is straightforward. Notice that we have not bound the state of the form fields to the state of the App component like we have previously done. React calls this kind of form uncontrolled.

Uncontrolled forms have certain limitations (for example, dynamic error messages or disabling the submit button based on an input are not possible). However uncontrolled forms are suitable enough for now.

If interested, you can read more about uncontrolled forms.

Let's review the highlighted code above carefully. The method handler addTask, mainly dispatches the action for adding tasks to our store:

addTask = (event) => {
  event.preventDefault()
  const content = event.target.task.value  event.target.task.value = ''
  store.dispatch({
    type: 'NEW_TASK',
    payload: {
      content,
      important: false,
      id: generateId()
    }
  })
}

We can get the content of the new task straight from our form's task input field. Because this field has a name, we access its content via the event object: event.target.task.value.

<form onSubmit={addTask}>
  <input name="task" />  <button type="submit">add</button>
</form>

A task's importance can be changed by clicking its name. The event handler is modest:

toggleImportance = (id) => {
  store.dispatch({
    type: 'TOGGLE_IMPORTANCE',
    payload: { id }
  })
}

Action creators

We begin to notice that, even in applications as primitive as ours, using Redux can simplify the frontend code. However, we can do a lot better.

React components don't need to know the Redux action types and forms. Let's separate the creation of action objects into functions:

const createTask = (content) => {
  return {
    type: 'NEW_TASK',
    payload: {
      content,
      important: false,
      id: generateId()
    }
  }
}

const toggleImportanceOf = (id) => {
  return {
    type: 'TOGGLE_IMPORTANCE',
    payload: { id }
  }
}

Functions that create these action objects are called action creators.

The App component does not have to know anything about the inner representation of the actions anymore, it just gets the right action by calling the creator function:

const App = () => {
  const addTask = (event) => {
    event.preventDefault()
    const content = event.target.task.value
    event.target.task.value = ''
    store.dispatch(createTask(content))  }
  
  const toggleImportance = (id) => {
    store.dispatch(toggleImportanceOf(id))  }

  // ...
}

Forwarding Redux Store to various components

Aside from the reducer, our application is in one file. 🧐 We should separate App into its module.

But how can the App access the store after the move? And more broadly, when a component is composed of many smaller components, there must be a way for all of the components to access the store. There are multiple ways to share the Redux store with components. The newest (and possibly easiest) way of sharing the store is by using the hooks API of the react-redux library.

First, let's install react-redux

npm i react-redux

Next, if you haven't done so already, move the App component into its own file App.jsx. Let's see how this affects the rest of the application files.

main.jsx becomes:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './App'
import taskReducer from './reducers/taskReducer'

const store = createStore(taskReducer)

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

Notice that the application is now defined as a child of a Provider component provided by the react-redux library. The application's store is given to the Provider as its attribute store.

Defining the action creators has been moved to the file reducers/taskReducer.js where the reducer is defined. That file looks like this:

const taskReducer = (state = [], action) => {
  // ...
}

const generateId = () =>
  Number((Math.random() * 1000000).toFixed(0))

export const createTask = (content) => {  return {
    type: 'NEW_TASK',
    payload: {
      content,
      important: false,
      id: generateId()
    }
  }
}

export const toggleImportanceOf = (id) => {  return {
    type: 'TOGGLE_IMPORTANCE',
    payload: { id }
  }
}

export default taskReducer

If the application has many components which need the store, the App component must pass store as props to all of those components.

The module now has multiple export commands.

A module can have only *one default export*, but *multiple normal exports*

export const createTask = (content) => {
  // ...
}

export const toggleImportanceOf = (id) => { 
  // ...
}

The reducer function is still returned with the export default command, so the reducer can be imported the usual way:

import taskReducer from './reducers/taskReducer'

In contrast, the normal exports (createTask and toggleImportanceOf) are imported via the curly brace syntax:

import { createTask } from './../reducers/taskReducer'

Below is the revised code for our App component

import { createTask, toggleImportanceOf } from './reducers/taskReducer'import { useSelector, useDispatch } from 'react-redux'
const App = () => {
  const dispatch = useDispatch()  const tasks = useSelector(state => state)
  const addTask = (event) => {
    event.preventDefault()
    const content = event.target.task.value
    event.target.task.value = ''
    dispatch(createTask(content))  }

  const toggleImportance = (id) => {
    dispatch(toggleImportanceOf(id))  }

  return (
    <div>
      <form onSubmit={addTask}>
        <input name="task" /> 
        <button type="submit">add</button>
      </form>
      <ul>
        {tasks.map(task =>          <li
            key={task.id} 
            onClick={() => toggleImportance(task.id)}
          >
            {task.content} <strong>{task.important ? '- important' : ''}</strong>
          </li>
        )}
      </ul>
    </div>
  )
}

export default App

There are a few things to observe in the code. Previously, the App dispatched actions by calling the dispatch method of the Redux store:

store.dispatch({
  type: 'TOGGLE_IMPORTANCE',
  payload: { id }
})

Now it does it with the dispatch function from the useDispatch hook.

import { useSelector, useDispatch } from 'react-redux'
const App = () => {
  const dispatch = useDispatch()  // ...

  const toggleImportance = (id) => {
    dispatch(toggleImportanceOf(id))  }

  // ...
}

The useDispatch hook provides *any React component access to main.jsx' dispatch's store*. This allows all components to make changes to the state of the Redux store.

The component can access the tasks stored in the store with the useSelector hook of the react-redux library.

import { useSelector, useDispatch } from 'react-redux'
const App = () => {
  // ...
  const tasks = useSelector(state => state)  // ...
}

useSelector receives a function as a parameter. The function either searches for or selects data from the Redux store. Here we need all of the tasks, so our selector function returns the whole state:

state => state

which is a shorthand for:

(state) => {
  return state
}

Usually, selector functions are a bit more interesting and return only selected parts of the contents of the Redux store. We could for example return only tasks marked as important:

const importantTasks = useSelector(state => state.filter(task => task.important))  

The current version of the application can be found on GitHub, branch part6-0.

More components

Let's separate creating a new task into a component (components/NewTask.js).

import { useDispatch } from 'react-redux'import { createTask } from '../reducers/taskReducer'
-const NewTask = () => {
  const dispatch = useDispatch()
  const addTask = (event) => {
    event.preventDefault()
    const content = event.target.task.value
    event.target.task.value = ''
    dispatch(createTask(content))  }

  return (
    <form onSubmit={addTask}>
      <input name="task" />
      <button type="submit">add</button>
    </form>
  )
}

export default NewTask

Unlike in the React code we did without Redux, the event handler for changing the state of the app (which now lives in Redux) has been moved away from the App to a child component. The logic for changing the state in Redux is still neatly separated from the whole React part of the application.

We'll also separate the list of tasks and displaying a single task into their own components (both of which will be placed in the components/Tasks.js file):

import { useDispatch, useSelector } from 'react-redux'import { toggleImportanceOf } from '../reducers/taskReducer'
const Task = ({ task, handleClick }) => {
  return(
    <li onClick={handleClick}>
      {task.content} 
      <strong> {task.important ? '- important' : ''}</strong>
    </li>
  )
}

const Tasks = () => {
  const dispatch = useDispatch()  const tasks = useSelector(state => state)
  return(
    <ul>
      {tasks.map(task =>
        <Task
          key={task.id}
          task={task}
          handleClick={() => 
            dispatch(toggleImportanceOf(task.id))
          }
        />
      )}
    </ul>
  )
}

export default Tasks

The logic for changing the importance of a task is now in the component managing the list of tasks.

With this refactoring, there is not much code left in App:

const App = () => {

  return (
    <div>
      <NewTask />
      <Tasks />
    </div>
  )
}

Task, responsible for rendering a single task, is very simple and is not aware that the event handler it gets as props dispatches an action. These kinds of components are called presentational in React terminology.

Tasks, on the other hand, is a container component, as it contains some application logic: it defines what the event handlers of the Task components do and coordinates the configuration of presentational components, that is, the Tasks.

We will return to the presentational/container division later on.

The code of the Redux application can be found on GitHub, branch part6-1.