Comment charger des données dans React avec redux-thunk, redux-saga, suspense & hooks

introduction

React est une bibliothèque JavaScript pour la création d'interfaces utilisateur. Très souvent, utiliser React signifie utiliser React avec Redux. Redux est une autre bibliothèque JavaScript pour gérer l'état global. Malheureusement, même avec ces deux bibliothèques, il n'y a pas de façon claire de gérer les appels asynchrones à l'API (backend) ou tout autre effet secondaire.

Dans cet article, j'essaie de comparer différentes approches pour résoudre ce problème. Définissons d'abord le problème.

Le composant X est l'un des nombreux composants du site Web (ou application mobile ou de bureau, c'est également possible). X interroge et affiche certaines données chargées depuis l'API. X peut être une page ou simplement une partie de la page. Chose importante, X est un composant séparé qui devrait être faiblement couplé avec le reste du système (autant que possible). X devrait afficher un indicateur de chargement pendant la récupération des données et une erreur si l'appel échoue.

Cet article suppose que vous avez déjà une certaine expérience de la création d'applications React / Redux.

Cet article va montrer 4 façons de résoudre ce problème et comparer les avantages et les inconvénients de chacun. Ce n'est pas un manuel détaillé sur la façon d'utiliser thunk, saga, suspence ou hooks .

Le code de ces exemples est disponible sur GitHub.

La configuration initiale

Serveur simulé

À des fins de test, nous allons utiliser json-server. C'est un projet incroyable qui vous permet de créer très rapidement de fausses API REST. Pour notre exemple, cela ressemble à ceci.

const jsonServer = require('json-server');const server = jsonServer.create();const router = jsonServer.router('db.json');const middleware = jsonServer.defaults();
server.use((req, res, next) => { setTimeout(() => next(), 2000);});server.use(middleware);server.use(router);server.listen(4000, () => { console.log(`JSON Server is running...`);});

Notre fichier db.json contient des données de test au format json.

{ "users": [ { "id": 1, "firstName": "John", "lastName": "Doe", "active": true, "posts": 10, "messages": 50 }, ... { "id": 8, "firstName": "Clay", "lastName": "Chung", "active": true, "posts": 8, "messages": 5 } ]}

Après le démarrage du serveur, un appel à // localhost: 4000 / users retourne la liste des utilisateurs avec une imitation de retard - environ 2s.

Projet et appel API

Nous sommes maintenant prêts à commencer à coder. Je suppose que vous avez déjà un projet React créé à l'aide de create-react-app avec Redux configuré et prêt à être utilisé.

Si vous avez des difficultés avec cela, vous pouvez vérifier ceci et cela.

L'étape suivante consiste à créer une fonction pour appeler l'API ( api.js ):

const API_BASE_ADDRESS = '//localhost:4000';
export default class Api { static getUsers() { const uri = API_BASE_ADDRESS + "/users";
 return fetch(uri, { method: 'GET' }); }}

Redux-thunk

Redux-thunk est un middleware recommandé pour la logique des effets secondaires Redux de base, telle que la logique asynchrone simple (comme une requête à l'API). Redux-thunk lui-même ne fait pas grand-chose. C'est juste 14 !!! lignes du code. Il ajoute simplement du «sucre de syntaxe» et rien de plus.

L'organigramme ci-dessous aide à comprendre ce que nous allons faire.

Chaque fois qu'une action est effectuée, le réducteur change d'état en conséquence. Le composant mappe l'état aux propriétés et utilise ces propriétés dans la méthode revder () pour déterminer ce que l'utilisateur devrait voir: un indicateur de chargement, des données ou un message d'erreur.

Pour que cela fonctionne, nous devons faire 5 choses.

1. Installez le tunk

npm install redux-thunk

2. Ajoutez le middleware thunk lors de la configuration du magasin (configureStore.js)

import { applyMiddleware, compose, createStore } from 'redux';import thunk from 'redux-thunk';import rootReducer from './appReducers';
export function configureStore(initialState) 

Dans les lignes 12–13, nous configurons également redux devtools. Un peu plus tard, cela aidera à montrer l'un des problèmes de cette solution.

3. Créez des actions (redux-thunk / actions.js)

import Api from "../api"
export const LOAD_USERS_LOADING = 'REDUX_THUNK_LOAD_USERS_LOADING';export const LOAD_USERS_SUCCESS = 'REDUX_THUNK_LOAD_USERS_SUCCESS';export const LOAD_USERS_ERROR = 'REDUX_THUNK_LOAD_USERS_ERROR';
export const loadUsers = () => dispatch => { dispatch({ type: LOAD_USERS_LOADING });
 Api.getUsers() .then(response => response.json()) .then( data => dispatch({ type: LOAD_USERS_SUCCESS, data }), error => dispatch() )};

Il est également recommandé de séparer vos créateurs d'actions (cela ajoute du codage supplémentaire), mais pour ce cas simple, je pense qu'il est acceptable de créer des actions «à la volée».

4. Créez un réducteur (redux-thunk / reducer.js)

import {LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";
const initialState = { data: [], loading: false, error: ''};
export default function reduxThunkReducer(state = initialState, action) { switch (action.type) { case LOAD_USERS_LOADING: { return { ...state, loading: true, error:'' }; } case LOAD_USERS_SUCCESS: { return { ...state, data: action.data, loading: false } } case LOAD_USERS_ERROR: { return { ...state, loading: false, error: action.error }; } default: { return state; } }}

5. Créez un composant connecté à redux (redux-thunk / UsersWithReduxThunk.js)

import * as React from 'react';import { connect } from 'react-redux';import {loadUsers} from "./actions";
class UsersWithReduxThunk extends React.Component { componentDidMount() { this.props.loadUsers(); };
 render() { if (this.props.loading) { return 
Loading }
 if (this.props.error) { return 
ERROR: {this.props.error} }
 return (  {this.props.data.map(u =><;td>{u.posts} )} 
First Name Last Name;Active?Posts Messages
{u.firstName} {u.lastName} {u.active ? 'Yes' : 'No'}{u.messages}
); }}
const mapStateToProps = state => ({ data: state.reduxThunk.data, loading: state.reduxThunk.loading, error: state.reduxThunk.error,});
const mapDispatchToProps = { loadUsers};
export default connect( mapStateToProps, mapDispatchToProps)(UsersWithReduxThunk);

J'ai essayé de rendre le composant aussi simple que possible. Je comprends que ça a l'air horrible :)

Indicateur de chargement

Les données

Erreur

Voilà: 3 fichiers, 109 lignes de code (13 (actions) + 36 (réducteur) + 60 (composant)).

Avantages:

  • Approche «recommandée» pour les applications react / redux.
  • Pas de dépendances supplémentaires. Presque, thunk est minuscule :)
  • Pas besoin d'apprendre de nouvelles choses.

Les inconvénients:

  • Beaucoup de code à différents endroits
  • 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

Redux-saga

Redux-saga is a redux middleware library designed to make handling side effects easy and readable. It leverages ES6 Generators 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 the thunk example is still applicable.

To make it work we need to do 6 things.

1. Install saga

npm install redux-saga

2. Add saga middleware and add all sagas (configureStore.js)

import { applyMiddleware, compose, createStore } from 'redux';import createSagaMiddleware from 'redux-saga';import rootReducer from './appReducers';import usersSaga from "../redux-saga/sagas";
const sagaMiddleware = createSagaMiddleware();
export function configureStore(initialState) 

Sagas from line 4 will be added in step 4.

3. Create action (redux-saga/actions.js)

export const LOAD_USERS_LOADING = 'REDUX_SAGA_LOAD_USERS_LOADING';export const LOAD_USERS_SUCCESS = 'REDUX_SAGA_LOAD_USERS_SUCCESS';export const LOAD_USERS_ERROR = 'REDUX_SAGA_LOAD_USERS_ERROR';
export const loadUsers = () => dispatch => { dispatch({ type: LOAD_USERS_LOADING });};

4. Create sagas (redux-saga/sagas.js)

import { put, takeEvery, takeLatest } from 'redux-saga/effects'import {loadUsersSuccess, LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";import Api from '../api'
async function fetchAsync(func) { const response = await func();
 if (response.ok) { return await response.json(); }
 throw new Error("Unexpected error!!!");}
function* fetchUser() { try { const users = yield fetchAsync(Api.getUsers);
 yield put({type: LOAD_USERS_SUCCESS, data: users}); } catch (e) { yield put({type: LOAD_USERS_ERROR, error: e.message}); }}
export function* usersSaga() { // Allows concurrent fetches of users yield takeEvery(LOAD_USERS_LOADING, fetchUser);
 // Does not allow concurrent fetches of users // yield takeLatest(LOAD_USERS_LOADING, fetchUser);}
export default usersSaga;

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

5. Create reducer (redux-saga/reducer.js)

import {LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";
const initialState = { data: [], loading: false, error: ''};
export default function reduxSagaReducer(state = initialState, action) { switch (action.type) { case LOAD_USERS_LOADING: { return { ...state, loading: true, error:'' }; } case LOAD_USERS_SUCCESS: { return { ...state, data: action.data, loading: false } } case LOAD_USERS_ERROR: { return { ...state, loading: false, error: action.error }; } default: { return state; } }}

The reducer here is absolutely the same as in the thunk example.

6. Create component connected to redux (redux-saga/UsersWithReduxSaga.js)

import * as React from 'react';import {connect} from 'react-redux';import {loadUsers} from "./actions";
class UsersWithReduxSaga extends React.Component { componentDidMount() { this.props.loadUsers(); };
 render() { if (this.props.loading) { return 
Loading }
 if (this.props.error) { return 
ERROR: {this.props.error} }
 return ( ;   {this.props.data.map(u => )} 
First Name Last Name;Active?Posts Messages
{u.firstName};{u.lastName}{u.active ? 'Yes' : 'No'}{u.posts}{u.messages}
); }}
const mapStateToProps = state => ({ data: state.reduxSaga.data, loading: state.reduxSaga.loading, error: state.reduxSaga.error,});
const mapDispatchToProps = { loadUsers};
export default connect( mapStateToProps, mapDispatchToProps)(UsersWithReduxSaga);

The component is also almost the same here as in the thunk example.

So here we have 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 rendering part of the component until some condition is met (for example data from the API loaded).

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

1. Create cache (suspense/cache.js)

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

import {createCache} from 'simple-cache-provider';
export let cache;
function initCache() { cache = createCache(initCache);}
initCache();

2. Create Error Boundary (suspense/ErrorBoundary.js)

This is an Error Boundary to catch errors thrown by Suspense.

import React from 'react';
export class ErrorBoundary extends React.Component { state = {};
 componentDidCatch(error) { this.setState(); }
 render() { if (this.state.error) { return 
ERROR: this.state.error ; }
 return this.props.children; }}
export default ErrorBoundary;

3. 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 a resource to get data from the API.

import * as React from 'react';import {createResource} from "simple-cache-provider";import {cache} from "./cache";import Api from "../api";
let UsersResource = createResource(async () => { const response = await Api.getUsers(); const json = await response.json();
 return json;});
class UsersTable extends React.Component { render() { let users = UsersResource.read(cache);
 return ( <;td>{u.posts} )} 
First Name;Last NameActive?PostsMessages
{u.firstName} {u.lastName} {u.active ? 'Yes' : 'No'}{u.messages}
); }}
export default UsersTable;

4. Create component (suspense/UsersWithSuspense.js)

import * as React from 'react';import UsersTable from "./UsersTable";import ErrorBoundary from "./ErrorBoundary";
class UsersWithSuspense extends React.Component { render() { return (   ); }}
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 React)
  • Delay of showing Loading indicator by setting dellayMs 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 are part of React).

Hooks

At the time of writing this article, hooks have not officially been 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 very soon. More details about hooks can be found here and here.

To make it work for our example we need to do one(!) thing:

1. Create and use hooks (hooks/UsersWithHooks.js)

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

import React, {useState, useEffect} from 'react';import Api from "../api";
function UsersWithHooks() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
 useEffect(async () => { try { const response = await Api.getUsers(); const json = await response.json();
 setData(json); } catch (e)  'Unexpected error'); 
 setLoading(false); }, []);
 if (loading) { return 
Loading }
 if (error) { return 
ERROR: {error} }
 return ( ;  {data.map(u => ; ;  )} 
First NameLast Name Active?PostsMessages
;{u.firstName}{u.lastName} {u.active ? 'Yes' : 'No'} {u.posts}{u.messages}
);}
export default UsersWithHooks;

And that’s it — just 1 file, 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 look, 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 learned (which are part of React)
  • Not officially released yet

Conclusion

Let’s organize these metrics as a table first.

  • 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 the project: its complexity, use cases, team knowledge, when the project is going to production, etc.
  • Saga can help with complex use cases
  • Suspense and Hooks are both worth considering (or at least learning) especially for new projects

That’s it — enjoy and happy coding!