Usando o Redux Saga

Usando o Redux Saga

Olá leitor, seja bem vindo a mais uma lição de Redux 😍

Hoje você vai aprender a utilizar uma funcionalidade bem bacana chamada de Redux Saga!

Até então, você já aprendeu a instalar e configurar, criar reducers, consumir reducers e na última aula aprendeu a criar actions!

Só com esses conhecimentos você já estará pronto para aplicar o redux em uma aplicação real.

Entretanto, haverá casos em que você vai desejar fazer requisições HTTP dentro das suas próprias actions, conceito conhecido como Efeito Colateral (ou sideEffects).

E a forma mais inteligente de se fazer isso é por meio do Redux Saga!

Pronto? 😜

O que é o Redux Saga?

O Redux Saga nada mais é do que uma biblioteca voltada para o gerencimento de efeitos colateriais em aplicações Redux com ReactJS.

Um efeito colateral pode ser considerado tarefas assíncronas, como chamadas de uma API, acesso ao armazenamento local, ou qualquer interação que não seja puramente computacional ou sincronizada.

O objetivo dessa biblioteca é lidar com esses efeitos colaterais de uma maneira mais controlada e fácil de testar, utilizando funções conhecidas como "sagas".

Funções "sagas" são funções geradoras (generators) do JavaScript que permitem uma abordagem declarativa para definir sequências complexas de operações assíncronas.

Tal processo que o torna uma ferramenta bastante poderosa para esse tipo de funcionalidade 😉

Criando seu projeto de testes

Antes de começarmos, vamos criar um novo projeto em ReactJS dedicado a esta lição. No meu caso criei um novo projeto chamado de redux-saga:

npx create-react-app redux-saga

Não se esqueça de instalar também as bibliotecas necessárias:

npm install redux react-redux @reduxjs/toolkit 

Diferente das outras lições, nesta, nós vamos criar novamente todas as configurações do Redux, e dos nossos componentes do ZERO!

Configurações iniciais do Redux

Para começar, vamos criar uma pasta chamada redux dentro de src com os seguintes arquivos:

redux > store.js:

import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./root-reducer";

export const store = configureStore({
 reducer: rootReducer
});

redux > root-reducer.js:

import { combineReducers } from "redux";

export default combineReducers({
});

Em seguida, vamos criar uma nova pasta chamada de user com um arquivo chamado de slice.js.

redux > user > slice.js:

import { createSlice } from '@reduxjs/toolkit';

const InitialState = {
 user: {
 id: null,
 nome: null
 }
}

export const userSlice = createSlice({
 name: 'user',
 initialState: InitialState,
 reducers: {

 }
});

export default userSlice.reducer;

Por fim, volte no root-reducer.js e adicione uma nova referência ao seu reducer que acabamos de criar.

redux > root-reducer.js:  

import { combineReducers } from "redux";
import userSlice from "./user/slice";

export default combineReducers({
 user: userSlice
});

Feito isso, partiu criar o nosso primeiro componente 🙂

Criando o componente <Nomes />

Já dentro da pasta components, você vai precisar criar um novo componente chamado de Nomes.

Nomes > index.jsx:

const Nomes = () => {
 return(
 <div style={{textAlign: 'center', width: '100%', height: '100vh', backgroundColor: '#3498db'}}>
 <h1 style={{color: 'white', fontSize: '40px', paddingTop: '30px'}}>Minha Lista de Nomes</h1>
 <p style={{color: '#2c3e50', fontSize: '24px', paddingTop: '10px'}}>A lista abaixo será carregada usando os efeitos colaterais do <strong>Redux Saga 💜</strong></p>
 <ul style={{color: 'white', fontSize: '24px', paddingTop: '18px'}}>
 <li>Nome 1</li>
 <li>Nome 2</li>
 <li>Nome 3</li>
 <li>Nome 4</li>
 <li>Nome 5</li>
 </ul>
 </div>
 );
}

export default Nomes;

Não se esqueça de chamar esse componente dentro de App.js:

import Nomes from "./components/Nomes";

export default function App() {
 return (
 <>
 <Nomes />
 </>
 );
}

Veja como ficou o resultado final:

Trabalhando com Redux Saga

Como vimos nos tópicos anteriores, a ideia é que o nosso componente <Nome />, chame uma action do Redux que seja capaz de fazer uma chamada em uma API externa, de modo a retornar alguns nomes de usuário.

Inicialmente você poderia imaginar o axios sendo chamado dentro de uma action, não é verdade?

🛑 Só que fazer uma requisição HTTP diretamente dentro de um action é uma prática não recomendada pelo time de Redux 🛑

Sendo assim, a única alternativa seria trabalhar com a funcionalidade side effect com sagas que existe no Redux.

Para isso, você vai precisar instalar essa nova biblioteca na sua aplicação.

Abra o seu terminal (prompt de comando) na pasta raiz do seu projeto, e execute o seguinte comando:

npm install redux-saga

E não se esqueça de instalar também o axios, pois iremos precisar dele para fazer requisições HTTP:

npm install axios

Feito isso, partiu criar o nosso primeiro saga 😄

Criando SAGAS

Como nós queremos que o reducer user faça uma requisição HTTP utilizando a biblioteca Redux Saga, a primeira coisa que devemos fazer é criar um novo arquivo chamado de sagaUser.js.

redux > user > sagaUser.js:

import { all, takeEvery } from 'redux-saga/effects';

function* fetchUser() {//Precisa ser o mesmo nome do action que faz o fetch
 return yield console.log('Função fetchUser do saga chamada!');
}

export default all([
 takeEvery('user/fetchUser', fetchUser)//Precisamos passar o type da action que faz o fetch e a função que faz o fetch dentro do takeEvery
])//Usamos um array para passar todos os sagas que queremos exportar

all e takeEvery são funções utilizadas para definir e combinar efeitos (sagas) assíncronos.

fetchUser é uma função geradora (indicada pelo function*), que define um saga do Redux Saga.

(export default function* rootSaga() { ... }): aqui definimos o rootSaga, que é o ponto de entrada para todos os sagas relacionados ao user.

Resumo: o código acima executa um console.log() sempre quando é chamada a função fetchUser por meio do slice.js.

E falando nesse arquivo, vamos modificá-lo da seguinte forma:

redux > user > slice.js:

import { createSlice } from '@reduxjs/toolkit';

const InitialState = {
 nomes: null
}

export const userSlice = createSlice({
 name: 'user',
 initialState: InitialState,
 reducers: {
 fetchUser: (state) => {
 console.log('Action FetchUser chamada!');
 }
 }
});

export const { fetchUser } = userSlice.actions;

export default userSlice.reducer;

No comando acima criamos uma nova action chamada de fetchUser que quando chamada executa uma mensagem "Action FetchUser chamada!", e automaticamente chama em conjunto o fetchUser que foi declarado em sagaUser.js.

Mas para que isso seja possível, nós precisamos configurar um middleware dentro do store.js da seguinte forma:

redux > store.js:

import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./root-reducer";

import createSagaMiddleware from 'redux-saga';
import rootSaga from "./root-sagas";

const sagaMiddleware = createSagaMiddleware();

export const store = configureStore({
 reducer: rootReducer,
 middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(sagaMiddleware)
});

sagaMiddleware.run(rootSaga)

Lembrando que o store.js implementa um arquivo chamado root-sagas.js que deve existir na mesma pasta desse arquivo, portanto, vamos criá-lo também:

redux > root-sagas.js:

import { all } from 'redux-saga/effects';
import sagaUser from './user/sagaUser';//Importe aqui os sagas que você quer exportar

export default function* rootSaga(){
 return yield all([
 sagaUser//Passe todos os seus sagas utilizando virgula!
 ])
}

No caso do arquivo store.jscreateSagaMiddleware(), é criado um middleware do Redux Saga que será utilizado para gerenciar efeitos colaterais assíncronos na sua aplicação Redux.

middleware: é uma função que recebe getDefaultMiddleware, que por sua vez, é uma função padrão do Redux Toolkit para obter o middleware padrão. A função concat é usada para adicionar o middleware do Redux Saga (sagaMiddleware) ao array de middleware padrão obtido.

sagaMiddleware.run(rootSaga): inicia a execução do rootSaga, o que é crucial para que todas as sagas definidas em rootSaga comecem a observar ações despachadas na store Redux, de modo a executarem suas lógicas assíncronas quando apropriado. (É aqui que o fetchUser de saga é executada em paralelo logo assim que chgamamos o fetchUser do slice.js).

Com relação ao root-sagas.js, aqui você deve importar todos os sagas que você deseja que sejam executados e reconhecidos na sua aplicação. Ele funciona de forma similar ao root-reducer.js.

return yield all([...]): Aqui o all([...]) é usado para agrupar todos os sagas que você importou e deseja executar.

sagaUser é passado como um elemento do array dentro de all([...]). Isso significa que sagaUser será executado quando o rootSaga for iniciado.

Chamando um Saga

Para testarmos o que fizemos no tópico anterior, que tal fazer a chamada do fetchUser dentro do nosso componente <Nomes />?

Nome > index.jsx:

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import { fetchUser } from '../../redux/user/slice';

const Nomes = () => {
 const dispatch = useDispatch();
 const nomes = useSelector(state => state.user.nomes); // Altere para o slice correto no seu estado

 useEffect(() => {
 dispatch(fetchUser()); // Dispara a ação para buscar os nomes do usuário
 }, [dispatch]);

 return(
 <div style={{textAlign: 'center', width: '100%', height: '100vh', backgroundColor: '#3498db'}}>
 <h1 style={{color: 'white', fontSize: '40px', paddingTop: '30px'}}>Minha Lista de Nomes</h1>
 <p style={{color: '#2c3e50', fontSize: '24px', paddingTop: '10px'}}>A lista abaixo será carregada usando os efeitos colaterais do <strong>Redux Saga 💜</strong></p>
 <ul style={{color: 'white', fontSize: '24px', paddingTop: '18px'}}>
 {nomes?.map(nome => (
 <li key={nome.id}>{nome.name}</li>
 ))}
 </ul>
 </div>
 );
}

export default Nomes;

No caso do comando acima, assim que o componente é montado na tela, o useEffect chama o fetchUser, que por sua vez, mostra a mensagem no console "Action FetchUser chamada!".

E como o fetchUser está ligado com o sagaUser, o fetchUser desse arquivo também é chamado em paralelo e mostra a mensagem "Função fetchUser do saga chamada!" no console.

Executando nosso projeto, veremos que nossas duas mensagens foram disparadas no console:

Legal, agora como implementamos a lógica de chamada na nossa API?

Fazendo chamadas HTTP dentro de um Saga

Antes de usarmos o axios, vamos criar mais duas actions que deverão ser chamadas quando o fetchUser der certo, e outra quando fetchUser der errado.

redux > user > slice.js:

import { createSlice } from '@reduxjs/toolkit';

const InitialState = {
 nomes: null
}

export const userSlice = createSlice({
 name: 'user',
 initialState: InitialState,
 reducers: {
 fetchUser: (state) => {
 console.log('Action FetchUser chamada!');
 },
 fetchSuccessUser: (state, action) => {
 console.log('Chamado deu sucesso!');
 console.log(action.payload);
 state.nomes = action.payload;
 },
 fetchErrorUser: (state, action) => {
 console.log('Chamado deu erro!');
 console.log(action.payload);
 }
 }
});

export const { fetchUser, fetchSuccessUser, fetchErrorUser } = userSlice.actions;

export default userSlice.reducer;

Note que criamos duas novas actions chamadas de fetchSuccessUserfetchErrorUser. E que serão chamadas de dentro do nosso sagaUser.js.

redux > user > sagaUser.js:

import { all, takeEvery, call, put } from 'redux-saga/effects';
import { fetchSuccessUser, fetchErrorUser } from './slice';

import axios from 'axios';

function* fetchUser() {//Precisa ser o mesmo nome do action que faz o fetch
 try{
 const response = yield call(axios.get, 'https://jsonplaceholder.typicode.com/users');//Fazemos uma chamada com o call. Não é possível usar o axios diretamente aqui
 console.log(response.data);//mostramos a resposta no console
 yield put(fetchSuccessUser(response.data));//Chamamos a action fetchSuccessUser passando a resposta da chamada
 }catch(error){
 yield put(fetchErrorUser(error.message));//Caso der um erro na chamada, chamamos a action fetchErrorUser passando o erro
 }
}

export default all([
 takeEvery('user/fetchUser', fetchUser)//Precisamos passar o type da action que faz o fetch e a função que faz o fetch dentro do takeEvery
])//Usamos um array para passar todos os sagas que queremos exportar

Note que estamos fazendo a chamada via axios por meio do comando call(), onde no final chamando nossas actions por meio do comando put().

Os comandos do código acima já são autoexplicativos, basta que você leia os comentários relacionadoas a ele 🙂

Abrindo a nossa aplicação, veremos que os nomes já estão sendo consumidos atraves da nossa API:

Adicionando um Loading no fetchUser

Os nomes foram carregados com sucesso, mas... supondo que quiséssemos adicionar uma especie de loading dentro do fetchUser, como faríamos?

Simples, basta adicionar uma nova chave chamada de loading dentro de initialState com valor padrão de false.

E sempre quando o usuário chamar o fetchUser, nós fazemos a mudançã desse valor para true, e dependendo do resultado da requisição, alteramos esse valor para false, observe:

redux > user > slice.js:

import { createSlice } from '@reduxjs/toolkit';

const InitialState = {
 nomes: null,
 loading: false,
}

export const userSlice = createSlice({
 name: 'user',
 initialState: InitialState,
 reducers: {
 fetchUser: (state) => {
 console.log('Action FetchUser chamada!');
 state.loading = true;
 },
 fetchSuccessUser: (state, action) => {
 console.log('Chamado deu sucesso!');
 console.log(action.payload);
 state.nomes = action.payload;
 state.loading = false;
 },
 fetchErrorUser: (state, action) => {
 console.log('Chamado deu erro!');
 console.log(action.payload);
 state.loading = false;
 }
 }
});

export const { fetchUser, fetchSuccessUser, fetchErrorUser } = userSlice.actions;

export default userSlice.reducer;

E no componente <Perfil />, não se esqueça de implementar essa lógica:

Perfil > index.jsx:

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import { fetchUser } from '../../redux/user/slice';

const Nomes = () => {
 const dispatch = useDispatch();
 const nomes = useSelector(state => state.user.nomes); // Altere para o slice correto no seu estado
 const loading = useSelector(state => state.user.loading); // Altere para o slice correto no seu estado

 useEffect(() => {
 dispatch(fetchUser()); // Dispara a ação para buscar os nomes do usuário
 }, [dispatch]);

 return(
 <div style={{textAlign: 'center', width: '100%', height: '100vh', backgroundColor: '#3498db'}}>
 <h1 style={{color: 'white', fontSize: '40px', paddingTop: '30px'}}>Minha Lista de Nomes</h1>
 <p style={{color: '#2c3e50', fontSize: '24px', paddingTop: '10px'}}>A lista abaixo será carregada usando os efeitos colaterais do <strong>Redux Saga 💜</strong></p>
 {loading ? (
 <p>Carregando nomes...</p>
 ) : (
 <ul style={{color: 'white', fontSize: '24px', paddingTop: '18px'}}>
 {nomes?.map(nome => (
 <li key={nome.id}>{nome.name}</li>
 ))}
 </ul>
 )}
 
 </div>
 );
}

export default Nomes;

Veja como ficou o resultado final:

Incrível, não?

Adicionando um delay na requisição do Saga

Caso você queria simular um aguardo antes de fazer a requisição para a API, você pode adicionar um delay() dentro do fetchUser da seguinte forma:

redux > user > sagaUser.js:

import { all, takeEvery, call, put, delay } from 'redux-saga/effects';
import { fetchSuccessUser, fetchErrorUser } from './slice';

import axios from 'axios';

function* fetchUser() {//Precisa ser o mesmo nome do action que faz o fetch
 try{
 yield delay(5000);//Adiciona um aguardo de 5 segundos...

 const response = yield call(axios.get, 'https://jsonplaceholder.typicode.com/users');//Fazemos uma chamada com o call. Não é possível usar o axios diretamente aqui
 console.log(response.data);//mostramos a resposta no console
 yield put(fetchSuccessUser(response.data));//Chamamos a action fetchSuccessUser passando a resposta da chamada
 }catch(error){
 yield put(fetchErrorUser(error.message));//Caso der um erro na chamada, chamamos a action fetchErrorUser passando o erro
 }
}

export default all([
 takeEvery('user/fetchUser', fetchUser)//Precisamos passar o type da action que faz o fetch e a função que faz o fetch dentro do takeEvery
])//Usamos um array para passar todos os sagas que queremos exportar

Ali dentro estamos passando o valor 5000 que representa 5 segundos.

Otimizando suas chamadas com o takeLatest

No caso do comando takeEvery, ele é um tipo de comando que será executado toda vez que chamarmos o fetchUser, ou seja, ele é executado toda vez que o componente é carregado, esse é o comportamento padrão dele.

O único problema, é que o componente <Nome /> poderia estar chamando o fetchUser por meio de um botão.

Então um usuário impaciênte, poderia clicar varias e varias vezes no botão, resultando em diversas chamadas em nossa API.

Para resolver este problema, temos a nossa disposição o takeLatest() que só pega a última ação que é executada desconsiderando todas as anteriores:

redux > user > sagaUser.js:

import { all, takeEvery, call, put, delay, takeLatest } from 'redux-saga/effects';
import { fetchSuccessUser, fetchErrorUser } from './slice';

import axios from 'axios';

function* fetchUser() {//Precisa ser o mesmo nome do action que faz o fetch
 try{
 yield delay(5000);//Adiciona um aguardo de 5 segundos...

 const response = yield call(axios.get, 'https://jsonplaceholder.typicode.com/users');//Fazemos uma chamada com o call. Não é possível usar o axios diretamente aqui
 console.log(response.data);//mostramos a resposta no console
 yield put(fetchSuccessUser(response.data));//Chamamos a action fetchSuccessUser passando a resposta da chamada
 }catch(error){
 yield put(fetchErrorUser(error.message));//Caso der um erro na chamada, chamamos a action fetchErrorUser passando o erro
 }
}

export default all([
 takeLatest('user/fetchUser', fetchUser)//Precisamos passar o type da action que faz o fetch e a função que faz o fetch dentro do takeEvery
])//Usamos um array para passar todos os sagas que queremos exportar

Dessa forma, por mais que nosso usuário chame o fetchUser por diversas vezes clicando no mesmo botão, somente um retorno a API seria realizado.

Adicionando novos efeitos colaterais com Reducers

Supondo que você queira adicionar mais de um único efeito colateral dentro de um arquivo saga, você pode fazer isso da seguinte forma:

redux > user > slice.js:

import { createSlice } from '@reduxjs/toolkit';

const InitialState = {
 nomes: null,
 loading: false,
}

export const userSlice = createSlice({
 name: 'user',
 initialState: InitialState,
 reducers: {
 fetchUser: (state) => {
 console.log('Action FetchUser chamada!');
 state.loading = true;
 },
 fetchSuccessUser: (state, action) => {
 console.log('Chamado deu sucesso!');
 console.log(action.payload);
 state.nomes = action.payload;
 state.loading = false;
 },
 fetchErrorUser: (state, action) => {
 console.log('Chamado deu erro!');
 console.log(action.payload);
 state.loading = false;
 },
 fetchSecondUser: (state) => {
 console.log('Action FetchSecondUser chamada!');
 state.loading = true;
 }
 }
});

export const { fetchUser, fetchSuccessUser, fetchErrorUser, fetchSecondUser } = userSlice.actions;

export default userSlice.reducer;

redux > user > sagaUser.js:

import { all, takeEvery, call, put, delay, takeLatest } from 'redux-saga/effects';
import { fetchSuccessUser, fetchErrorUser } from './slice';

import axios from 'axios';

function* fetchUser() {//Precisa ser o mesmo nome do action que faz o fetch
 try{
 yield delay(5000);//Adiciona um aguardo de 5 segundos...

 const response = yield call(axios.get, 'https://jsonplaceholder.typicode.com/users');//Fazemos uma chamada com o call. Não é possível usar o axios diretamente aqui
 console.log(response.data);//mostramos a resposta no console
 yield put(fetchSuccessUser(response.data));//Chamamos a action fetchSuccessUser passando a resposta da chamada
 }catch(error){
 yield put(fetchErrorUser(error.message));//Caso der um erro na chamada, chamamos a action fetchErrorUser passando o erro
 }
}

function* fetchSecondUser(){
 try{
 const response = yield call(axios.get, 'https://jsonplaceholder.typicode.com/users');//Fazemos uma chamada com o call. Não é possível usar o axios diretamente aqui
 console.log(response.data);//mostramos a resposta no console

 yield console.log('Implemente um fetchSuccessSecondUser para tratar o erro!');
 }catch(error){
 yield console.log('Implemente um fetchErrorSecondUser para tratar o erro!');
 }
}

export default all([
 takeLatest('user/fetchUser', fetchUser),
 takeEvery('user/fetchSecondUser', fetchSecondUser)
]);

Por fim, não se esqueça de implementar o fetchSecondUser no seu componente <Nomes />😉

Arquivos da lição

Os arquivos dessa lição podem ser encontrados neste repositório do GitHub.

Conclusão

Nesta lição você aprendeu a usar Redux Sagas e realizar efeitos colaterais com ele 😉

Até a próxima lição.