Loading Data In React - Redux-Thunk, Redux-Saga, Suspense, Hooks

Introduction

React is a JavaScript library for building user interfaces. Very often using React means using React with Redux. Redux is another JavaScript library for managing global state. Sadly, even with these two libraries there is no one clear way to handle asynchronous calls to the API (backend) or any other side effects.

In this article I’m trying to compare different approaches to solving this problem. Let’s define the problem first.

Component X is one of the many components of the web site (or mobile, or desktop application, it’s also possible). X queries and shows some data loaded from the API. X can be page or just part of the page. Important thing that X is a separate component which should be loosely coupled with the rest of the system (as much as possible). X should show loading indicator while data is retrieving and error if call fails.

This article assumes that you already have some experience with creating React/Redux applications.

This article is going to show 4 ways of solving this problem and compare pros and cons of each one. It isn’t detailed a manual how to use thunk, saga, suspense or hooks.

Code of these examples is available on GitHub.

Initial setup

 
Mock Server

For testing purposes we are going to use json-server. It’s an amazing project that allows us to build fake REST APIs very quickly. For our example it looks like this.

  1. const jsonServer = require('json-server');  
  2. const server = jsonServer.create();  
  3. const router = jsonServer.router('db.json');  
  4. const middleware = jsonServer.defaults();  
  5.   
  6. server.use((req, res, next) => {  
  7.    setTimeout(() => next(), 2000);  
  8. });  
  9. server.use(middleware);  
  10. server.use(router);  
  11. server.listen(4000, () => {  
  12.    console.log(`JSON Server is running...`);  
  13. }); 

db.json file contains test data in json format.

  1. {  
  2.  "users": [  
  3.    {  
  4.      "id": 1,  
  5.      "firstName""John",  
  6.      "lastName""Doe",  
  7.      "active"true,  
  8.      "posts": 10,  
  9.      "messages": 50  
  10.    },  
  11.    ...  
  12.    {  
  13.      "id": 8,  
  14.      "firstName""Clay",  
  15.      "lastName""Chung",  
  16.      "active"true,  
  17.      "posts": 8,  
  18.      "messages": 5  
  19.    }  
  20.  ]  
  21. }  

After starting the server, a call to the http://localhost:4000/users returns the list of the users with a delay of about 2s.

Project and API call

Now we are ready to start coding. I assume that you already have a React project created using create-react-app with Redux configured and ready to use.

If you have any difficulties with it you can check out this and this.

The next is to create a function to call API (api.js)

  1. const API_BASE_ADDRESS = 'http://localhost:4000';  
  2.   
  3. export default class Api {  
  4.    static getUsers() {  
  5.        const uri = API_BASE_ADDRESS + "/users";  
  6.   
  7.        return fetch(uri, {  
  8.            method: 'GET'  
  9.        });  
  10.    }  
  11. }  

Redux-thunk

Redux-thunk is a recommended middleware for basic Redux side effects logic such as simple async logic like request to the API. Redux-thunk itself doesn’t do a lot. It’s just 14 Llnes of the code! It just adds some “syntax sugar” and nothing more.

Flowchart below helps us to understand what we are going to do.

Loading Data In React - Redux-Thunk, Redux-Saga, Suspense, Hooks 

Every time an action is performed the reducer changes state accordingly. Component maps state to properties and uses these properties in revder() method to figure out what the user should see: loading indicator, data or error message.

To make it work we need to do 5 things.

Install tunk

npm install redux-thunk

Add thunk middleware when configuring store (configureStore.js),
  1. import { applyMiddleware, compose, createStore } from 'redux';  
  2. import thunk from 'redux-thunk';  
  3. import rootReducer from './appReducers';  
  4.   
  5. export function configureStore(initialState) {  
  6.  const middleware = [thunk];  
  7.   
  8.  const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;  
  9.  const store = createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middleware)));  
  10.   
  11.  return store;  
  12. }  

In line 12-13 we also configure reduxdevtools. A bit later it will help to show one of the problems with this solution.

Create actions (redux-thunk/actions.js)
  1. import Api from "../api"  
  2.   
  3. export const LOAD_USERS_LOADING = 'REDUX_THUNK_LOAD_USERS_LOADING';  
  4. export const LOAD_USERS_SUCCESS = 'REDUX_THUNK_LOAD_USERS_SUCCESS';  
  5. export const LOAD_USERS_ERROR = 'REDUX_THUNK_LOAD_USERS_ERROR';  
  6.   
  7. export const loadUsers = () => dispatch => {  
  8.    dispatch({ type: LOAD_USERS_LOADING });  
  9.   
  10.    Api.getUsers()  
  11.        .then(response => response.json())  
  12.        .then(  
  13.            data => dispatch({ type: LOAD_USERS_SUCCESS, data }),  
  14.            error => dispatch({ type: LOAD_USERS_ERROR, error: error.message || 'Unexpected Error!!!' })  
  15.        )  
  16. };  

It’s also recommended to have action creators separated (it adds some additional codding), but for this simple case I think it’s acceptable to create actions “on the fly”.

Create reduser (redux-thunk/reducer.js)
  1. import {LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";  
  2.   
  3. const initialState = {  
  4.    data: [],  
  5.    loading: false,  
  6.    error: ''  
  7. };  
  8.   
  9. export default function reduxThunkReducer(state = initialState, action) {  
  10.    switch (action.type) {  
  11.        case LOAD_USERS_LOADING: {  
  12.            return {  
  13.                ...state,  
  14.                loading: true,  
  15.                error:''  
  16.            };  
  17.        }  
  18.        case LOAD_USERS_SUCCESS: {  
  19.            return {  
  20.                ...state,  
  21.                data: action.data,  
  22.                loading: false  
  23.            }  
  24.        }  
  25.        case LOAD_USERS_ERROR: {  
  26.            return {  
  27.                ...state,  
  28.                loading: false,  
  29.                error: action.error  
  30.            };  
  31.        }  
  32.        default: {  
  33.            return state;  
  34.        }  
  35.    }  
  36. }  

Create component connected to redux (redux-thunk/UsersWithReduxThunk.js),

  1. import * as React from 'react';  
  2. import { connect } from 'react-redux';  
  3. import {loadUsers} from "./actions";  
  4.   
  5. class UsersWithReduxThunk extends React.Component {  
  6.    componentDidMount() {  
  7.        this.props.loadUsers();  
  8.    };  
  9.   
  10.    render() {  
  11.        if (this.props.loading) {  
  12.            return <div>Loading</div>  
  13.        }  
  14.   
  15.   
  16.        if (this.props.error) {  
  17.            return <div style={{ color: 'red' }}>ERROR: {this.props.error}</div>  
  18.        }  
  19.   
  20.        return (  
  21.            <table>  
  22.                <thead>  
  23.                <tr>  
  24.                    <th>First Name</th>  
  25.                    <th>Last Name</th>  
  26.                    <th>Active?</th>  
  27.                    <th>Posts</th>  
  28.                    <th>Messages</th>  
  29.                </tr>  
  30.                </thead>  
  31.                <tbody>  
  32.                {this.props.data.map(u =>  
  33.                    <tr key={u.id}>  
  34.                        <td>{u.firstName}</td>  
  35.                        <td>{u.lastName}</td>  
  36.                        <td>{u.active ? 'Yes' : 'No'}</td>  
  37.                        <td>{u.posts}</td>  
  38.                        <td>{u.messages}</td>  
  39.                    </tr>  
  40.                )}  
  41.                </tbody>  
  42.            </table>  
  43.        );  
  44.    }  
  45. }  
  46.   
  47. const mapStateToProps = state => ({  
  48.    data: state.reduxThunk.data,  
  49.    loading: state.reduxThunk.loading,  
  50.    error: state.reduxThunk.error,  
  51. });  
  52.   
  53. const mapDispatchToProps = {  
  54.    loadUsers  
  55. };  
  56.   
  57. export default connect(  
  58.    mapStateToProps,  
  59.    mapDispatchToProps  
  60. )(UsersWithReduxThunk);  

I tried to make the component as simple as possible. I understand that it looks awful :)

Loading indicator

Loading Data In React - Redux-Thunk, Redux-Saga, Suspense, Hooks 

Data

Loading Data In React - Redux-Thunk, Redux-Saga, Suspense, Hooks 

Error

Loading Data In React - Redux-Thunk, Redux-Saga, Suspense, Hooks 

3 files, 109 line of code (13(actions) + 36(reducer) + 60(component)).

Pros
  • “Recommended” approach for react/redux applications.
  • No additional dependencies. Also, thunk is tiny :)
  • No need to learn new things.
Cons
  • A lot of code in different places
  • After navigation to another page, old data is still in the global state (see picture below). This data is outdated and useless information that consumes memory.
  • In case of complex scenarios (multiple conditional calls in one action, etc.) code isn’t very readable
Loading Data In React - Redux-Thunk, Redux-Saga, Suspense, Hooks 

Redux-saga

Redux-saga is a redux middleware library designed to make handling side effects in an easy and readable way. It leverages an ES6 Generator which allows us to write asynchronous code that looks synchronous. Also, this solution is easy to test.

From a high level perspective this solution works the same as thunk. The flowchart from thunk example is still applicable.

To make it work we need to do 6 things.

Install saga

npm install redux-thunk

 Add saga middleware and add all sagas (configureStore.js),
  1. import { applyMiddleware, compose, createStore } from 'redux';  
  2. import createSagaMiddleware from 'redux-saga';  
  3. import rootReducer from './appReducers';  
  4. import usersSaga from "../redux-saga/sagas";  
  5.   
  6. const sagaMiddleware = createSagaMiddleware();  
  7.   
  8. export function configureStore(initialState) {  
  9.  const middleware = [sagaMiddleware];  
  10.   
  11.  const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;  
  12.  const store = createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middleware)));  
  13.   
  14.  sagaMiddleware.run(usersSaga);  
  15.   
  16.  return store;  
  17. }  

Sagas from line 4 will be added in step 4.

Create action (redux-saga/actions.js)
  1. export const LOAD_USERS_LOADING = 'REDUX_SAGA_LOAD_USERS_LOADING';  
  2. export const LOAD_USERS_SUCCESS = 'REDUX_SAGA_LOAD_USERS_SUCCESS';  
  3. export const LOAD_USERS_ERROR = 'REDUX_SAGA_LOAD_USERS_ERROR';  
  4.   
  5. export const loadUsers = () => dispatch => {  
  6.    dispatch({ type: LOAD_USERS_LOADING });  
  7. };  

Create sagas (redux-saga/sagas.js)

  1. import { put, takeEvery, takeLatest } from 'redux-saga/effects'  
  2. import {loadUsersSuccess, LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";  
  3. import Api from '../api'  
  4.   
  5. async function fetchAsync(func) {  
  6.    const response = await func();  
  7.   
  8.    if (response.ok) {  
  9.        return await response.json();  
  10.    }  
  11.   
  12.    throw new Error("Unexpected error!!!");  
  13. }  
  14.   
  15. function* fetchUser() {  
  16.    try {  
  17.        const users = yield fetchAsync(Api.getUsers);  
  18.   
  19.        yield put({type: LOAD_USERS_SUCCESS, data: users});  
  20.    } catch (e) {  
  21.        yield put({type: LOAD_USERS_ERROR, error: e.message});  
  22.    }  
  23. }  
  24.   
  25. export function* usersSaga() {  
  26.    // Allows concurrent fetches of users  
  27.    yield takeEvery(LOAD_USERS_LOADING, fetchUser);  
  28.   
  29.    // Does not allow concurrent fetches of users  
  30.    // yield takeLatest(LOAD_USERS_LOADING, fetchUser);  
  31. }  
  32.   
  33. export default usersSaga;  

Saga has quite a steep learning curve, so if you’ve never used it and never read anything about this framework it could be difficult to understand what’s going on here. Briefly, in userSaga function we configure saga to listen to LOAD_USERS_LOADING action and trigger fetchUsersfunction. fetchUsersfunction calls API. If call is successful, then LOAD_USER_SUCCESS action is dispatched, otherwise LOAD_USER_ERROR action is dispatched.

Create reducer (redux-saga/reducer.js)
  1. import {LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";  
  2.   
  3. const initialState = {  
  4.    data: [],  
  5.    loading: false,  
  6.    error: ''  
  7. };  
  8.   
  9. export default function reduxSagaReducer(state = initialState, action) {  
  10.    switch (action.type) {  
  11.        case LOAD_USERS_LOADING: {  
  12.            return {  
  13.                ...state,  
  14.                loading: true,  
  15.                error:''  
  16.            };  
  17.        }  
  18.        case LOAD_USERS_SUCCESS: {  
  19.            return {  
  20.                ...state,  
  21.                data: action.data,  
  22.                loading: false  
  23.            }  
  24.        }  
  25.        case LOAD_USERS_ERROR: {  
  26.            return {  
  27.                ...state,  
  28.                loading: false,  
  29.                error: action.error  
  30.            };  
  31.        }  
  32.        default: {  
  33.            return state;  
  34.        }  
  35.    }  
  36. }  

Reducer is absolutely the same as in the thunk example.

Create component connected to redux (redux-saga/UsersWithReduxSaga.js)
  1. import * as React from 'react';  
  2. import {connect} from 'react-redux';  
  3. import {loadUsers} from "./actions";  
  4.   
  5. class UsersWithReduxSaga extends React.Component {  
  6.    componentDidMount() {  
  7.        this.props.loadUsers();  
  8.    };  
  9.   
  10.    render() {  
  11.        if (this.props.loading) {  
  12.            return <div>Loading</div>  
  13.        }  
  14.   
  15.   
  16.        if (this.props.error) {  
  17.            return <div style={{color: 'red'}}>ERROR: {this.props.error}</div>  
  18.        }  
  19.   
  20.        return (  
  21.            <table>  
  22.                <thead>  
  23.                    <tr>  
  24.                        <th>First Name</th>  
  25.                        <th>Last Name</th>  
  26.                        <th>Active?</th>  
  27.                        <th>Posts</th>  
  28.                        <th>Messages</th>  
  29.                    </tr>  
  30.                </thead>  
  31.                <tbody>  
  32.                    {this.props.data.map(u =>  
  33.                        <tr key={u.id}>  
  34.                            <td>{u.firstName}</td>  
  35.                            <td>{u.lastName}</td>  
  36.                            <td>{u.active ? 'Yes' : 'No'}</td>  
  37.                            <td>{u.posts}</td>  
  38.                            <td>{u.messages}</td>  
  39.                        </tr>  
  40.                    )}  
  41.                </tbody>  
  42.            </table>  
  43.        );  
  44.    }  
  45. }  
  46.   
  47. const mapStateToProps = state => ({  
  48.    data: state.reduxSaga.data,  
  49.    loading: state.reduxSaga.loading,  
  50.    error: state.reduxSaga.error,  
  51. });  
  52.   
  53. const mapDispatchToProps = {  
  54.    loadUsers  
  55. };  
  56.   
  57. export default connect(  
  58.    mapStateToProps,  
  59.    mapDispatchToProps  
  60. )(UsersWithReduxSaga);  

Component is also almost the same as in the thunk example.

4 files, 136 line of code (7(actions) + 36(reducer) + sagas(33) + 60(component)).

Pros
  • More readable code (async/await)
  • Good for handling complex scenarios (multiple conditional calls in one action, action can have multiple listeners, canceling actions, etc.)
  • Easy to unit test
Cons
  • A lot of code in different places
  • After navigation to another page, old data is still in the global state. This data is outdated and useless information that consumes memory.
  • Additional dependency
  • A lot of concepts to learn

Suspense

Suspense is a new feature in React 16.6.0. It allows us to defer to the rendering part of component until some condition is met (for example data from API loaded).

To make it work we need to do 4 things (it’s definitely getting better :) ).

Create cache (suspense/cache.js)

For cache we are going to use simple-cache-provider which is a basic cache provider for react applications.

  1. import {createCache} from 'simple-cache-provider';  
  2. export let cache;  
  3. function initCache() {  
  4.  cache = createCache(initCache);  
  5. }  
  6. initCache();  

Create Error Boundary (suspense/ErrorBoundary.js)

It’s an Error Boundary to catch errors thrown by Suspense.

  1. import React from 'react';  
  2. export class ErrorBoundary extends React.Component {  
  3.  state = {};  
  4.  componentDidCatch(error) {  
  5.    this.setState({ error: error.message || "Unexpected error" });  
  6.  }  
  7.  render() {  
  8.    if (this.state.error) {  
  9.      return <div style={{ color: 'red' }}>ERROR: {this.state.error || 'Unexpected Error'}</div>;  
  10.    }  
  11.    return this.props.children;  
  12.  }  
  13. }  
  14. export default ErrorBoundary;  

Create Users Table (suspense/UsersTable.js)

For this example, we need to create an additional component which loads and shows data. Here we are creating resources to get data from API.

  1. import * as React from 'react';  
  2. import {createResource} from "simple-cache-provider";  
  3. import {cache} from "./cache";  
  4. import Api from "../api";  
  5.   
  6.   
  7. let UsersResource = createResource(async () => {  
  8.    const response = await Api.getUsers();  
  9.    const json = await response.json();  
  10.   
  11.    return json;  
  12. });  
  13.   
  14. class UsersTable extends React.Component {  
  15.    render() {  
  16.        let users = UsersResource.read(cache);  
  17.   
  18.        return (  
  19.            <table>  
  20.                <thead>  
  21.                <tr>  
  22.                    <th>First Name</th>  
  23.                    <th>Last Name</th>  
  24.                    <th>Active?</th>  
  25.                    <th>Posts</th>  
  26.                    <th>Messages</th>  
  27.                </tr>  
  28.                </thead>  
  29.                <tbody>  
  30.                {users.map(u =>  
  31.                    <tr key={u.id}>  
  32.                        <td>{u.firstName}</td>  
  33.                        <td>{u.lastName}</td>  
  34.                        <td>{u.active ? 'Yes' : 'No'}</td>  
  35.                        <td>{u.posts}</td>  
  36.                        <td>{u.messages}</td>  
  37.                    </tr>  
  38.                )}  
  39.                </tbody>  
  40.            </table>  
  41.        );  
  42.    }  
  43. }  
  44.   
  45. export default UsersTable;  

Create component (suspense/UsersWithSuspense.js)

  1. import * as React from 'react';  
  2. import UsersTable from "./UsersTable";  
  3. import ErrorBoundary from "./ErrorBoundary";  
  4.   
  5. class UsersWithSuspense extends React.Component {  
  6.    render() {  
  7.        return (  
  8.            <ErrorBoundary>  
  9.                <React.Suspense fallback={<div>Loading</div>}>  
  10.                    <UsersTable/>  
  11.                </React.Suspense>  
  12.            </ErrorBoundary>  
  13.        );  
  14.    }  
  15. }  
  16.   
  17. export default UsersWithSuspense;  

4 files, 106 line of code (9(cache) + 19(ErrorBoundary) + UsersTable(33) + 45(component)).

3 files, 87 line of code (9(cache) + UsersTable(33) + 45(component)) if we assume that ErrorBoundary is a reusable component.
 
Pros
  • No redux needed. This approach can be used without redux. Component is fully independent.
  • No additional dependencies (simple-cache-provider is part of the React)
  • Delay of showing Loading indicator by setting delayMs property
  • Fewer lines of code than in previous examples

Cons

  • Cache is needed even when we don’t really need caching.
  • Some new concepts need to be learned (which is part of the React).

Hooks

By the time of writing this article hooks are not officially released yet and available only in the “next” version. Hooks are indisputably one of the most revolutionary upcoming features which can change a lot in the React world in the near future. More details about hooks are here and here.

To make it work for our example we need to do one thing

Create and use hooks (hooks/UsersWithHooks.js)

Here we are creating 3 hooks (functions) to “hook into” React state.

  1. import React, {useState, useEffect} from 'react';  
  2. import Api from "../api";  
  3.   
  4. function UsersWithHooks() {  
  5.    const [data, setData] = useState([]);  
  6.    const [loading, setLoading] = useState(true);  
  7.    const [error, setError] = useState('');  
  8.   
  9.    useEffect(async () => {  
  10.        try {  
  11.            const response = await Api.getUsers();  
  12.            const json = await response.json();  
  13.   
  14.            setData(json);  
  15.        } catch (e) {  
  16.            setError(e.message || 'Unexpected error');  
  17.        }  
  18.   
  19.        setLoading(false);  
  20.    }, []);  
  21.   
  22.    if (loading) {  
  23.        return <div>Loading</div>  
  24.    }  
  25.   
  26.    if (error) {  
  27.        return <div style={{color: 'red'}}>ERROR: {error}</div>  
  28.    }  
  29.   
  30.    return (  
  31.        <table>  
  32.            <thead>  
  33.            <tr>  
  34.                <th>First Name</th>  
  35.                <th>Last Name</th>  
  36.                <th>Active?</th>  
  37.                <th>Posts</th>  
  38.                <th>Messages</th>  
  39.            </tr>  
  40.            </thead>  
  41.            <tbody>  
  42.            {data.map(u =>  
  43.                <tr key={u.id}>  
  44.                    <td>{u.firstName}</td>  
  45.                    <td>{u.lastName}</td>  
  46.                    <td>{u.active ? 'Yes' : 'No'}</td>  
  47.                    <td>{u.posts}</td>  
  48.                    <td>{u.messages}</td>  
  49.                </tr>  
  50.            )}  
  51.            </tbody>  
  52.        </table>  
  53.    );  
  54. }  
  55.   
  56. export default UsersWithHooks;  

1 files, 56 line of code!!!!.

Pros
  • No redux needed. This approach can be used without redux. Component is fully independent.
  • No additional dependencies
  • About 2 times less code than in other solutions
Cons
  • At first glance, the code looks weird and difficult to read and understand. It will take some time to get used to hooks.
  • Some new concepts need to be learnt (which is part of the React)
  • Not official release yet

Conclusion

Let’s organize metrics as a table first.

  Files Lines of code Dependencies Redux needed?
Thunk 3 109 0.001 yes
Saga 4 136 1 yes
Suspense 4/3 106/87 0 no
Hooks 1 56 0 no
  • Redux is still a good option to manage global state (if you have it)
  • Each option has pros and cons. Which approach is better depends on project: complexity, use cases, team knowledge, when project is going to production, etc.
  • Saga can help with complex use cases
  • Suspense and Hooks are worth considering (or at least learning) especially for the new projects

That's it — enjoy and happy coding!