Artigo original escrito por: Valerii Tereshchenko
Artigo original: How to load data in React with redux-thunk, redux-saga, suspense & hooks
Traduzido e adaptado por: Thiago Costa Barbosa

Introdução

React é uma biblioteca JavaScript para construir interfaces de usuário. Muitas vezes, usar React significa usar React com Redux. Redux é outra biblioteca JavaScript para gerenciar o estado global. Infelizmente, mesmo com essas duas bibliotecas, não há uma maneira clara de lidar com chamadas assíncronas para a API (back-end) ou quaisquer outros efeitos colaterais.

Neste artigo, tentarei comparar diferentes abordagens para resolver esse problema. Primeiro, vamos definir o problema.

O Componente X é um dos muitos componentes de um site (ou aplicação para dispositivos móveis ou para desktop). X consulta e mostra alguns dados carregados da API. X pode ser uma página ou apenas parte da página. É importante também que X seja um componente separado, que deve ser minimamente acoplado com o resto do sistema (tanto quanto possível). X deve mostrar um indicador de carregamento enquanto os dados estão sendo recuperados e um erro se ela falhar.

Este artigo pressupõe que você já tenha alguma experiência com a criação de aplicativos React/Redux.

Nele, mostraremos 4 formas de resolver o problema e comparar os prós e contras de cada uma. Lembramos apenas que esse não é um manual detalhado sobre como usar Thunk, Saga, Suspense ou Hooks.

O código dos exemplos está disponível no GitHub.

Configuração inicial

Servidor somente para testes (mock)

Para fins de teste, usaremos o json-server. É um projeto incrível que permite construir simulações de APIs REST muito rapidamente. Para o nosso exemplo, ele será apenas isso:

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 está rodando...`);
});

Nosso arquivo db.json contém os dados de teste no formato 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
   }
 ]
}

Após iniciar o servidor, uma chamada para http://localhost:4000/users retornará a lista de usuários com uma simulação de atraso na resposta — em cerca de 2 segundos.

Projeto e chamada da API

Agora, estamos prontos para começar a programar. Suponho que você já tenha um projeto em React criado usando o create-react-app com o Redux configurado e pronto para usar.

Se você tiver alguma dificuldade com isso, você pode verificar como fazê-lo na documentação do create-react-app ou aqui (texto em inglês).

A próxima etapa é criar uma função para chamar a API ( api.js ):

const API_BASE_ADDRESS = 'http://localhost:4000';

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

Redux-thunk

O redux-thunk é um middleware recomendado para lógica básica dos efeitos colaterais do Redux, como uma lógica assíncrona simples (tipo uma solicitação para a API). O próprio redux-thunk não fará muita coisa. São apenas 14(!!!) linhas de código . Ele apenas adiciona um pouco de "aprimoramento sintático" e nada mais.

O fluxograma abaixo ajudará você a entender o que faremos.

AWinRkydsUiojdtNEqS9O7NO6xtGirYJR50Z

Toda vez que uma ação é executada, o reducer muda o state de acordo. O componente mapeia o state para as propriedades e usa essas propriedades no método render() para descobrir o que o usuário deve ver: um indicador de carregamento, os dados ou uma mensagem de erro.

Para fazê-lo funcionar, precisamos fazer 5 coisas.

1. Instalar o Thunk

npm install redux-thunk

2. Adicionar o middleware de conversão ao configurar o store (configureStore.js)

import { applyMiddleware, compose, createStore } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './appReducers';

export function configureStore(initialState) {
 const middleware = [thunk];
 
 const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
 const store = createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middleware)));
 
 return store;
}

Nas linhas 12 e 13, também configuramos o redux devtools. Depois, ele ajudará a mostrar um dos problemas com esta solução.

3. Criar as ações (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({ type: LOAD_USERS_ERROR, error: error.message || 'Erro inesperado!!!' })
       )
};

Também é recomendável separar os criadores das ações (sim, isso adiciona um pouco mais de programação), mas para este caso simples acho que podemos criar as ações em conjunto no mesmo arquivo.

4. Criar o reducer (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. Criar o componente, já conectado ao 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 <div>Loading</div>
       }
       
       if (this.props.error) {
           return <div style={{ color: 'red' }}>ERROR: {this.props.error}</div>
       }
    
       return (
           <table>
               <thead>
                   <tr>
                       <th>First Name</th>
                       <th>Last Name</th>
                       <th>Active?</th>
                       <th>Posts</th>
                       <th>Messages</th>
                   </tr>
               </thead>
               <tbody>
               {this.props.data.map(u =>
                   <tr key={u.id}>
                       <td>{u.firstName}</td>
                       <td>{u.lastName}</td>
                       <td>{u.active ? 'Yes' : 'No'}</td>
                       <td>{u.posts}</td>
                       <td>{u.messages}</td>
                   </tr>
               )}
               </tbody>
           </table>
       );
   }
}

const mapStateToProps = state => ({
   data: state.reduxThunk.data,
   loading: state.reduxThunk.loading,
   error: state.reduxThunk.error,
});

const mapDispatchToProps = {
   loadUsers
};

export default connect(
   mapStateToProps,
   mapDispatchToProps
)(UsersWithReduxThunk);

Sei que o código está horrível, mas entenda que tentei tornar o componente o mais simples possível :)

Indicador de carregamento

8QkfJzj7pl5LgP2-BgjeVPXDdf7jFmPxCXIp

Exibição dos Dados

TxQRy0VYOb-Z1EwwwwdIfVHa8xYSd7443FuG

Exibição do erro

u2AmrUXMxuxeEHJ0RRtm0Et9YpkQDbSJhZx3

Esse é o resultado: 3 arquivos, 109 linhas de código ( 13(ações) + 36(reducer) + 60(componente) ).

Prós:

  • Abordagem “recomendada” para aplicativos react/redux.
  • Sem dependências adicionais. Bom, quase... convenhamos que o thunk ali é minúsculo :)
  • Não há necessidade de aprender nada novo.

Contras:

  • Muito código em lugares diferentes
  • Após a navegação para outra página, os dados antigos ainda estarão no state global (veja a imagem abaixo). Esses dados são informações desatualizadas e inúteis que agora só estão consumindo memória.
  • No caso de cenários complexos (várias chamadas condicionais em uma ação, etc.) o código não será muito legível
TfXqLWBchdCUCvNjDXbfWihuWZNV2BVQaH3r

Redux-saga

Redux-saga é uma biblioteca de middleware redux projetada para tornar fácil e legível a manipulação de efeitos colaterais Ela aproveita os generators ES6, que nos permitem escrever código assíncrono que parece síncrono. Além disso, esta solução é fácil de testar.

Caso não saiba, Generators são funções especiais que podem ser executadas, pausadas e continuadas em diferentes estágios de sua execução..

De uma perspectiva de alto nível, esta solução funciona da mesma forma que o thunk. E o fluxograma do exemplo de conversão ainda se aplica.

Para fazê-lo funcionar, precisamos fazer 6 coisas.

1. Instalar o saga

npm install redux-saga

2. Adicionar o middleware saga e todas as 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) {
 const middleware = [sagaMiddleware];
    
 const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
 const store = createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middleware)));
    
 sagaMiddleware.run(usersSaga);
    
 return store;
}

As Sagas da linha 4 serão adicionadas na etapa 4.

3. Criar a ação (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. Criar as 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() {
   // Permite buscas simultâneas de usuários
   yield takeEvery(LOAD_USERS_LOADING, fetchUser);
    
   // Não permite buscas simultâneas de usuários
   // yield takeLatest(LOAD_USERS_LOADING, fetchUser);
}

export default usersSaga;

O Saga tem uma curva de aprendizado bem íngreme. Então, se você nunca usou ou leu nada sobre esse framework, pode ser difícil entender o que está acontecendo aqui. Resumidamente, na função userSaga configuramos o saga para escutar a ação LOAD_USERS_LOADING e acionar a função fetchUsers . A função fetchUsers chama a API. Se a chamada for bem-sucedida, a ação LOAD_USER_SUCCESS será despachada, caso contrário, a ação LOAD_USER_ERROR será despachada (dispatched, em inglês).

5. Criar um 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;
       }
   }
}

O reducer aqui é absolutamente igual ao exemplo do thunk.

6. Criar o componente conectado ao 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 <div>Loading</div>
       }
       
       if (this.props.error) {
           return <div style={{color: 'red'}}>ERROR: {this.props.error}</div>
       }
    
       return (
           <table>
               <thead>
                   <tr>
                       <th>First Name</th>
                       <th>Last Name</th>
                       <th>Active?</th>
                       <th>Posts</th>
                       <th>Messages</th>
                   </tr>
               </thead>
               <tbody>
                   {this.props.data.map(u =>
                       <tr key={u.id}>
                           <td>{u.firstName}</td>
                           <td>{u.lastName}</td>
                           <td>{u.active ? 'Yes' : 'No'}</td>
                           <td>{u.posts}</td>
                           <td>{u.messages}</td>
                       </tr>
                   )}
               </tbody>
           </table>
       );
   }
}

const mapStateToProps = state => ({
   data: state.reduxSaga.data,
   loading: state.reduxSaga.loading,
   error: state.reduxSaga.error,
});

const mapDispatchToProps = {
   loadUsers
};

export default connect(
   mapStateToProps,
   mapDispatchToProps
)(UsersWithReduxSaga);

O componente também é quase o mesmo que no exemplo do thunk.

Aqui, nós temos: 4 arquivos, 136 linhas de código ( 7(ações) + 36(reducer) + 33(sagas) + 60(componente) ).

Prós:

  • Código mais legível (async/await)
  • Bom para lidar com cenários complexos (várias chamadas condicionais em uma ação, uma ação ter vários ouvintes, cancelar ações etc.)
  • Teste unitário fácil

Contras:

  • Muito código em lugares diferentes
  • Após a navegação para outra página, os dados antigos também permanecem no state global.
  • Dependência adicional
  • Muitos conceitos para aprender

Suspense

Suspense é um novo recurso do React 16.6.0. Ele nos permite adiar a renderização de parte do componente até que alguma condição seja atendida (como por exemplo, os dados da API estarem completamente carregados).

Para fazê-lo funcionar, precisamos fazer 4 coisas (definitivamente, está melhorando :) ).

1. Criar o cache (suspense/cache.js)

Para o cache, usaremos o simple-cache-provider que é um provedor de cache básico para uma aplicação React.

import {createCache} from 'simple-cache-provider';

export let cache;

function initCache() {
 cache = createCache(initCache);
}

initCache();

2. Criar um Error Boundary - ou Limite de Erro (suspense/ErrorBoundary.js)

Essa classe nos permitirá pegar os erros lançados pelo Suspense.

import React from 'react';

export class ErrorBoundary extends React.Component {
 state = {};

 componentDidCatch(error) {
   this.setState({ error: error.message || "Unexpected error" });
 }

 render() {
   if (this.state.error) {
     return <div style={{ color: 'red' }}>ERROR: {this.state.error || 'Unexpected Error'}</div>;
   }

   return this.props.children;
 }
}

export default ErrorBoundary;

3. Criar uma tabela de usuários (suspense/UsersTable.js)

Para este exemplo, precisamos criar um componente adicional que carrega e mostra os dados. Para isso, vamos criar um recurso para obter os dados da 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 (
           <table>
               <thead>
               <tr>
                   <th>First Name</th>
                   <th>Last Name</th>
                   <th>Active?</th>
                   <th>Posts</th>
                   <th>Messages</th>
               </tr>
               </thead>
               <tbody>
               {users.map(u =>
                   <tr key={u.id}>
                       <td>{u.firstName}</td>
                       <td>{u.lastName}</td>
                       <td>{u.active ? 'Yes' : 'No'}</td>
                       <td>{u.posts}</td>
                       <td>{u.messages}</td>
                   </tr>
               )}
               </tbody>
           </table>
       );
   }
}

export default UsersTable;

4. Criar o componente (suspense/UsersWithSuspense.js)

import * as React from 'react';
import UsersTable from "./UsersTable";
import ErrorBoundary from "./ErrorBoundary";

class UsersWithSuspense extends React.Component {
   render() {
       return (
           <ErrorBoundary>
               <React.Suspense fallback={<div>Loading</div>}>
                   <UsersTable/>
               </React.Suspense>
           </ErrorBoundary>
       );
   }
}

export default UsersWithSuspense;

Chegamos ao final com: 4 arquivos, 106 linhas de código (9(cache) + 19(ErrorBoundary) + UsersTable(33) + 45(component)).

Ou, se assumirmos que ErrorBoundary é um componente reutilizável: 3 arquivos, 87 linhas de código ( 9(cache) + UsersTable(33) + 45(component) ).

Prós:

  • Não é necessário o redux. Essa abordagem pode ser usada sem o redux. Componente totalmente independente.
  • Sem dependências adicionais ( pois o simple-cache-provider faz parte do React)
  • Atraso na exibição do indicador de carregamento definindo a propriedade delayMs
  • Menos linhas de código do que nos exemplos anteriores

Contras:

  • O cache é necessário mesmo quando não precisamos de cache.
  • Alguns novos conceitos (que já fazem parte do React) precisam ser aprendidos.

Hooks

Os hooks são, indiscutivelmente, um dos recursos mais revolucionários no mundo do React. Mais detalhes sobre hooks podem ser encontrados aqui e aqui.

Para fazê-lo funcionar para o nosso exemplo, precisamos fazer apenas uma (!) coisa:

1. Criar e usar hooks (hooks/UsersWithHooks.js)

Aqui estamos criando 3 hooks (funções) para "engatar" no state do React.

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 function fetchData() {
           try {
               const response = await Api.getUsers();
               const json = await response.json();

            setData(json);
           } catch (e) {
               setError(e.message || 'Unexpected error');
           }

           setLoading(false);
       }

       fetchData();
   }, []);
    
   if (loading) {
       return <div>Loading</div>
   }
    
   if (error) {
       return <div style={{color: 'red'}}>ERROR: {error}</div>
   }

   return (
       <table>
           <thead>
           <tr>
               <th>First Name</th>
               <th>Last Name</th>
               <th>Active?</th>
               <th>Posts</th>
               <th>Messages</th>
           </tr>
           </thead>
           <tbody>
           {data.map(u =>
               <tr key={u.id}>
                   <td>{u.firstName}</td>
                   <td>{u.lastName}</td>
                   <td>{u.active ? 'Yes' : 'No'}</td>
                   <td>{u.posts}</td>
                   <td>{u.messages}</td>
               </tr>
           )}
           </tbody>
       </table>
   );
}

export default UsersWithHooks;

E é isso - apenas 1 arquivo, 56 linhas de código!!!

Prós:

  • Não é necessário o redux. Essa abordagem pode ser usada sem redux. Componente totalmente independente.
  • Sem dependências adicionais
  • Cerca de 2x menos código do que em outras soluções

Contras:

  • À primeira vista, o código parece estranho e difícil de ler e entender. Mas leva pouco tempo para se acostumar com os hooks.
  • Alguns novos conceitos precisam ser aprendidos (que também fazem parte do React)

Conclusão

Vamos organizar essas métricas primeiro como tabela.

Em2SE0unpxwElJ45-SaSNQZ15H0eGDdJQqIj
  • Redux ainda é uma boa opção para gerenciar o state global (se você o tiver)
  • Cada opção tem prós e contras. Qual abordagem é a melhor? Vai depender das características do projeto: sua complexidade, casos de uso, conhecimento da equipe, quando o projeto vai para a produção etc.
  • O Saga pode ajudar com casos de uso complexos
  • Suspense e Hooks valem a pena ser considerados (ou pelo menos aprender sobre eles), especialmente para novos projetos

Uma nota do tradutor para aprofundar seus estudos:

Aqui nós comparamos diferentes maneiras de carregar dados de uma API. Mas muitas vezes em aplicações web, você vai precisar atualizar os dados com frequência para mostrar informações relevantes ao usuário. A pesquisa curta é uma das maneiras de fazer isso. Confira este artigo (em inglês) para mais detalhes…

É isso — aproveite a leitura e uma ótima programação para você!