Borak - software developer illustrational
Blog
8.8. 2020 / 21:03

front end

architecture

State Management

Redux

Redux Saga

State Management In React Applications

Managing state as application’s complexity grows can easily become tricky. However there are some rules, methods and tools, which can make this task easier and keep the architecture of the system neat and readable. Let’s tame the beast in the next few lines.

Types of state

To be able to address the topic, common issues and their solutions, we need first some clarification of what we are dealing with. There is some common language on the topic already settled.

State from the point of view of the methods of its persistence and its purpose can be divided into following types:
  • Persistent
  • Client
  • Server
  • Ui
  • transient
  • Router

Persistent State

Persistent state is everything the client can see after the authentication process kicks in. It is mostly stored on the server and is retrieved after every login and the following initialisation of the application.

This state is mostly shared by the other two types of state. Client state and the Server State.

Client State

This state is everything stored on the individual client the application runs at. It is mostly initialised by the fetch of the server state. But it mostly differs from the server state in some details. Not everything on the server is necessarily present in the client state. On top of that, Client state can be personalised by web browser storage capabilities (Session Storage, Local Storage, Indexed DB).

Server State

This is everything that is supposed to have lifespan independent of the lifespan of the client session and at the same time is save on the backend premises. Server state usually makes up substantive part of the Persistent state.

UI state

Mostly short-lived state of the individual parts of the application. In React application, it’s mostly implemented as state of the component – useState hook of functional component or setState of class-based component. Sometimes can be partly stored in the client or on the server and be a part of the persistent state.

Transient State

This state comes from the users interaction with the application. It is often bound to the particular client or user account. For example, when you watch clip on the Youtube and get back to it later on, you will be offered to start from the point you stopped watching last time. However, if you pass the link to the clip to your friend who never watched it before, he will be offered to start watching from the very beginning of the clip.

Router State

This is state of the router of the application. Parameters, query values and path to page are stored in it. It is mostly handled by libraries in SPA environment. In React applications, the most popular – at least counting the NPM weekly downloads – is react-router.

Introduction of sample application in initial state

I created a simplistic application for the purpose of this article, which is hosted on github:
https://github.com/PetrBorak/state-management-react

To make it run on your machine, reproduce the following steps in terminal:Of course, I suppose, you have already Node.js  and git present on your machine.

The application contains, on top of what the create-react-app will produce, the following main part, which we will work with here:
  • The Todos component
    • state-management-react/src/todosComponent
  • The backend service
    • state-management-react/src/backend

TODO Component

The Todos component handles the fetch of the todos and their rendering. At this phase, it contains the following code:

import React, { ReactDOM, useEffect, useState } from 'react'
import { fetchTodos, getTodos } from "../backend/backend";
 
export const Todos = (props) => {
const [todos, setTodos] = useState([])

const filterSelection = (ev) =>  
  fetchTodos(ev.target.value).then((result) => {
    setTodos(result)
 });

useEffect(() => {
 fetchTodos().then((result) => {
   setTodos(result)
         })
     }, []);
 
return ( <div>
            <h2>HELLO WORLD FROM TODOS COMPONENT</h2>
            <select onChange={filterSelection}>
                <option value={'ALL'}>ALL</option>
                <option value={'DONE'}>DONE</option>
                <option value={'WAITING'}>WAITING</option>
            </select>
            <h2>TODOS:</h2>
            {todos.map((todo)=><div><h3>{todo.title}</h3>
            {todo.body}<input type='checkbox' checked=
            {todo.state==='DONE'}/>
            </div>)}
         </div>
       )
 }
It renders the individual todos, their title and checkbox indicating, whether the todo is waiting for competition or is already done.

The component is functional and uses useEffect and useState hooks:
  • UseEffect fetches the todos, when the component mounts
  • UseState stores the fetched todos for the component
More about functional hooks can be read here: https://reactjs.org/docs/hooks-intro.html

Backend Service

Backend service only handles the fetching of the todos and creates a request string based on parameters passed to it.

Here is the code for it:
export const fetchTodos = (params) => fetch(`http://localhost:4000/todos${params ? '?filter=' + params : ''}`).then((result) => result.json());

Drawbacks

Well, the application is fully functional now. It fetches data and parses them properly. Everything seems perfectly reasonable. However, there are a few drawbacks to this solution.

State is adhoc

Keeping the persistent state, or parts of it in the individual components is a real risk for the application and can incur danger of design rot.
For now, we are more or less OK with this solution, but as the complexity of the application grows, the danger will become more and more obvious. One of the dangers is pretty tricky.

Namely the accidental mutation in combination with side effects.

Mutation hides changes, and hidden changes brings non-determinism into the application. We do not want non-deterministic behavior in the app.

To avoid problems with this kind of problem,  system of tools has evolved, that handles state in the unidirectional, readable way and keep the mutation away with the help of combination of pure functions and transaction records about the changes done in the state.

Yep, your quess is probably right. I am talking about Redux right now. I won’t elaborate more about why it’s so useful for the architecture of the app. You can always read more, for example in this article:
https://redux.js.org/introduction/getting-started

Actually, the first sentence of this article is very good summary of my previous words:

“Redux is predictable state container for Javascript apps.”

Connection of redux to the application

To avoid the aforementioned risks, let’s link the Todos component with Redux. We move the ad-hoc state to Redux container and amend the fetchTodos function. This function now calls the backend and, in case of successful fetch updates the Redux store with new set of Todos.

To do that, we need to define the Store by adding the following files to our solution:

  • state-management-react/src/store
  • state-management-react/src/store/todos
    • here are the action creators and reducers for our store
  • state-management-react/src/store/todos/todos.js
    • the file with reducers and initial state
  • state-management-react/src/store/todos/todosActions.js
    • the file with action definitions and action creators
Lets look at the content of this new files briefly:

state-management-react/src/store/todos:

const initialStateTodos = {
  todos: []
}
 
export const todosReducer = (state = initialStateTodos, action) => {
 switch(action.type){
  case 'FETCH_PENDING':
   return {...state, loading: true}
  case 'FETCH_SUCCESS':
   return {...state, todos: action.payload}
  case 'FETCH_ERROR':
   return {...state, error: true}
  default:
   return state;
     }
 }

state-management-react/src/store/todos/todosActions.js:

export const todosActionCreatorFetchSuccess = (payload) => ({
  type: 'FETCH_SUCCESS',
  payload: payload
 })
 
export const todosActionCreatorFetchError = () => ({
  type: 'FETCH_ERROR',
 })
 
export const todosActionCreatorFetchPending = () => ({
  type: 'FETCH_PENDING',
 })


And this is how the Todos component looks now:

import React, { useEffect, useCallback } from 'react'
import { fetchTodos } from "../backend/backend";
import {connect} from "react-redux";
 
import { todosActionCreatorFetchSuccess, todosActionCreatorFetchError, todosActionCreatorFetchPending} from '../store/todos/todosActions'
 
export const Todos = (props) => {
 const { fetchTodosPending, fetchTodosSuccess, todos } = props;
 
 const fetchAndStore = useCallback((param = '') => {
    fetchTodosPending()
    fetchTodos(param).then((result) => {
    fetchTodosSuccess(result)
   })
 }, [fetchTodosSuccess, fetchTodosPending])
 
const filterSelection = (ev) => fetchAndStore(ev.target.value)
 
useEffect(() => {
  fetchAndStore()
 }, [fetchAndStore]);
 
return (<div>
            <h2>HELLO WORLD FROM TODOS COMPONENT</h2>
            <select onChange={filterSelection}>
                <option value={'ALL'}>ALL</option>
                <option value={'DONE'}>DONE</option>
                <option value={'WAITING'}>WAITING</option>
            </select>
            <h2>TODOS:</h2>
            {todos.map((todo)=><div><h3>{todo.title}</h3>
            {todo.body}<input type='checkbox' checked=
            {todo.state==='DONE'}/>
            </div>)}
         </div>
    )
 }
 
const mapDispatchToProps = (dispatch) => ({
  fetchTodosSuccess: (payload) =>    
  dispatch(todosActionCreatorFetchSuccess(payload)),
  fetchTodosPending: () =>  
  dispatch(todosActionCreatorFetchPending()),
  fetchTodosError: () => dispatch(todosActionCreatorFetchError()),
 })
 
const mapStateToProps = (state) => {
 return {
   todos: state.todos
   }
 }
 
export default connect(mapStateToProps, mapDispatchToProps)(Todos)
The most important part is that in the fetchAndStore function we now call the individual actions in the store and in case of successful fetch we dispatch the final FETCH_SUCCESS action with payload containing the individual todos.

This is how our state management in the application looks like right now
Img 01 - initial state of the application 

Further tuning

The component now handles the fetch through the backend service and dispatches appropriate actions in the Redux store.

The filters state

The state of the filters is adhoc and transient. Not to mention, that because we do the new fetch each time the filters change, we have no record of them in the global Redux state.

The state is simply not part of the running application. We won’t change it, because its sufficient for the purpose of this tutorial. The shape of state is always a subject to discussion across the development team.

Side effects In Redux application

As already mentioned, the reducers should be pure functions and the state amended by them should be immutable.
But the way, we fetch the data to the application right now – by calling the backend service straight from the Todos component - is not very good solution either. That’s basically another side effect.

This kind of side effect influences the architecture of the application in a bad way. It cannot be so obvious right now, as our example is really simple, but again – as the complexity of the application grows, the modularity of it with this ad hoc solution is seriously impacted. The system’s design rot can easily take place.

(More about the design rot here: https://fi.ort.edu.uy/innovaportal/file/2032/1/design_principles.pdf)

Luckily for us, we have solutions to this problem. There is no one altogether right solution. Everything about the right architecture is more or less a set of advices, principles and patterns to follow.

So, to keep the architecture unaffected and to avoid the system design rot while scaling up, we should try to shift as much side effects as possible to places dedicated to them.

As this fetch call is basically a side effect closely linked to global state, which we have in Redux, we should move it to the place appropriate to Redux side effects.

One particulary popular solution for side effects in Redux is Redux Saga.


Implementation of Redux Saga

Again, this article is not intended to elaborate on how the libraries really work. You can read more about Redux Saga here:
https://redux-saga.js.org/

This time we change the App.js file to add Redux Saga to it. The code can be seen in our repo in branch Phase03.

Here is the code of the App.js in case, you do not have the repo downloaded:
import React from 'react';
 import './App.css';
 import { Route, Router } from 'react-router'
 import { createBrowserHistory } from 'history'
 import { createStore, applyMiddleware, compose } from 'redux';
 import { Provider } from 'react-redux';
 import createSagaMiddleware from 'redux-saga'
 
 import { todosReducer } from './store/todos/todos'
 import rootSaga from "./store/middleware/sagas";
 import Todos from "./todosComponent/Todos";
 
 const sagaMiddleware = createSagaMiddleware()
 const store = createStore(
  todosReducer,
  compose(applyMiddleware(sagaMiddleware),
   window.__REDUX_DEVTOOLS_EXTENSION__    
   && window.__REDUX_DEVTOOLS_EXTENSION__())
 )

 sagaMiddleware.run(rootSaga)
 
function App() {
return (<Router history={createBrowserHistory()}>
          <Provider store={store}>
              <Route path={'/'} component={Todos}></Route>
          </Provider>
      </Router>
   );
 }
 
 export default App;
Here is the path to the file with our sagas, we added to the application:
  • src/store/middleware/sagas.js
And here is the content of this file:

 import { call, delay, put, takeEvery } from 'redux-saga/effects'
 import { fetchTodos } from "../../backend/backend";
 import { todosActionCreatorFetchSuccess, todosActionCreatorFetchPending} from '../todos/todosActions'
 
 export function* fetchTodosSaga(action) {
  yield put(todosActionCreatorFetchPending());
  const result = yield call(fetchTodos, action.payload)
  yield put(todosActionCreatorFetchSuccess(result));
 }
 
 export function* watchFetchTodos() {
  yield takeEvery('FETCH_TODOS', fetchTodosSaga)
 }

 export default function* rootSaga() {
    yield watchFetchTodos()
 }
In our Todos component (src/todosComponent/Todos.jsx) we now change the handling of the fetch only by dispatching the action in the store.
This is, where the Redux Saga kicks in. It listens for the FETCH_TODOS action and handles the fetch through the backend service. If the fetch succeeds, it calls another FETCH_SUCCESS action. The FETCH_SUCCESS is then handled by Redux reducer and stores the result of the fetch to the store.

The component receives the individual toodos from the store – this is handled by react-redux library.

The state handling solution now looks like this:
Img 02 - after implementation of Redux Saga 

The backend service is not a part of our Todos component anymore, but is imported straight in the sagas handling the fetch.

The router state

As you can see the router state, though its present in the application in the form of react-router, is now completely isolated.

One of the problems with this is, that we are not able to mirror the state of the fiters in the URL for now. That means, that when putting the application’s filters into some state, we are not able to reproduce this information other way, than setting up the filters by hand every time the app is loaded.

Once the user refresh or copy the URL to application, the filters state is back to its default values.

The URL should be always handled as the primary source of true in the application. The reason for that is, that user can always interact straight with the URL through the omnibox.

As we have already implemented the mean to handle the state in the application by Redux, the solution to router is simply to create the URL state a part of the global Redux state.

Connection of router to the Redux state

Let’s connect the router to Redux. Again, there are many solutions we can choose. So, we will choose the most verified and thus popular library – connected-react-router.

More info about this library: https://www.npmjs.com/package/connected-react-router

As mentioned in the intro to react-router (https://reactrouter.com/web/guides/deep-redux-integration), it is not recommended to handle router changes in the store. As for example by listening to actions.

As for me, this is shame, because, if we were able to listen for route changes and were given enough information about the route (in our case, we need info about parameters choosen), we could isolate the fetch altogether.

All the same, even the connected-react-router library doesn’t give us enough information about chosen params.

So, let’s do it the proposed way. We will keep handling the Todos component as container component and keep the fetching logic there. But this time, as we already implemented the react-router, we will use the fact, we are given the params from the URL and pass them to the backend service.

You can see the final source code in the Phase04 branch.

As we have almost everything implemented already, we need to do only slight changes in the source code of the Todo component. So, here is the code:

 import React, { useEffect, useCallback } from 'react'
 import {connect} from "react-redux";
 import { Link, useParams } from "react-router-dom";
 import { todosActionCreatorFetch } from '../store/todos/todosActions'
 
 export const Todos = (props) => {
  const { todosActionCreatorFetch, todos, history } = props;
  let { filter } = useParams();
 
  const fetchAndStore = useCallback((param = '') => {
   todosActionCreatorFetch(param)
  }, [todosActionCreatorFetch, filter])
 
const filterSelection = (ev) => history.push(ev.target.value)
 
useEffect(() => {
 fetchAndStore(filter)
}, [fetchAndStore]);
 
return (<div>
            <h2>HELLO WORLD FROM TODOS COMPONENT</h2>
            <select onChange={filterSelection}>
                <option value={'ALL'}>ALL</option>
                <option value={'DONE'}>DONE</option>
                <option value={'WAITING'}>WAITING</option>
            </select>
            <h2>TODOS:</h2>
            {todos.map((todo)=><div><h3>{todo.title}</h3>
            {todo.body}<input type='checkbox' checked=
            {todo.state==='DONE'}/>
            </div>)}
         </div>
   )
 }
 
 const mapDispatchToProps = (dispatch) => ({
  todosActionCreatorFetch: (param) =>   
   dispatch(todosActionCreatorFetch(param))
 })
 
 const mapStateToProps = (state) => {
  return {
   todos: state.todos.data
  }
}
 
 export default connect(mapStateToProps, mapDispatchToProps)(Todos)
Our final solution for the state of the application looks like this:
Img 03 - Connecting the router state 

Conclusion

The state handling is one of most important architectural decisions in the development of the application. In this article, we came through different phases and attitudes to handling the state.

The development, with its everchanging requirements, is always work in progress. Where the solution from our Phase01 branch is enough in one point of time, the solution can be a palpable bottleneck when our application grows in the future.

Each of the solutions depicted in this article can be suitable for SPA development. It’s up to development team to choose the most appropriate solution.

For example, take the initial solution from Phase01 branch and later, as the complexity of the application grows, implement some of the more sophisticated.

Main points in the lifecycle of our solution

State one

  • Filter done and not done
  • Structure
    • Backend and its state for todos in one go
    • Fetch is called from the component
      • Component has hard coded filter values
      • Component has hardcoded default values for filters
      • Backend fetches the todos for the filter combination and stores them in its state
    • When filter changes in the component, backend is called and calls the endpoint for the combination of filters
      •  This will not enable passing the combination of filters by copying the URL as the filters are not part of it

State two

  • Global state now isolated from the local state previously saved in the component part
  • The component now calls backend (fetchArcitles method) and dispatches actions 
    • FETCH PENDING
    • FETCH SUCCESS
    • FETCH ERROR
  • The backend is used only for the call of the endpoint (for combination of filters)
  • When change occurs the component dispatches action
    • The component calls new backend fetch (fetchArcitles method)
  • The backend service is now completely stateless
    • Advantages
      • No mutation
      • No race conditions
        • Mutation hides changes
        • Hidden changes lead to non determinism

State three

  • Actions  - the same for now
  • The fetching of todos goes to the saga
  • New action FETCH TODOS
    • Dispatched by component
  • Saga calls the backend with filter passed to FETCH_TODOS as payload
  • When OK, saga dispatches FETCH SUCCESS action
  • When backend error, saga dispatches FETCH ERROR
    • Not implemented for this article
  • Backend calls the endpoint and is called from the saga
  • Component dispatches the FETCH_TODOS action

State four

  • Params are added to the route components route property
  • Connection to the application
  • The component now fetches todos based on its params properties
  • Change to filters amends routing
  • Change to route call component
    • Component calls the FETCH_TODOS action

More by Borak

To maximalize your user experience during visit to my page, I use cookies.More info
I understand

#BORAKlive

This page is subjected to the Creative Common Licence. Always cite the Author - Do not use the page's content on commercial basis. Comply with the licence 3.0 Czech Republic.
go to top