Paralelismo, race conditions, tudo acontecendo ao mesmo tempo!

Redux Saga: Você no controle das operações assíncronas

Eduardo Rabelo
Published in
11 min readDec 8, 2016

--

Há alguns dias atrás, um conhecido meu deu uma palestra falando sobre como controlar operações assíncronas. Ele estava usando várias bibliotecas para extender as possibilidades do Redux. E, escutando o que ele dizia, eu senti aquela fadiga do ecossistema JavaScript.

Vamos ser diretos: Se você está fazendo seu trabalhando usando tecnologia baseda no que você precisa — e não só porque ela é “legal” — organizar um projeto React ainda pode ser frustrante.

Eu passei os últimos dois anos trabalhando em projetos Angular e eu até estava gostando do modelo Model-View-Controller, funcionava! E eu preciso dizer — mesmo se a linha de aprendizado fosse um problema para quem estava migrando de Backbone.js — aprender Angular realmente valeu a pena. Eu consegui um emprego melhor, tive a oportunidade de contribuir para projetos interessantes e eu aprendi muito na comunidade ao redor do Angular.

Dias que valeram a pena, mas, como sempre, a fadiga deve continuar (ainda estou em processo de patentear essa frase), e agora eu estou indo direto ao mundo fashion de: React, Redux e Sagas.

A alguns anos atrás, eu encontrei esse artigo intitulado Flattening Promise Chains por Thomas Burleson. Eu aprendi bastante coisa lendo esse artigo. Mesmo dois anos depois, eu ainda lembro dos momentos “a-há”, que tive ao ler o artigo.

Nos dias de hoje, eu tenho usado React, e a combinação de Redux com sagas, para controlar as operações assíncronas, é muito poderosa. Então, eu estou escrevendo esse artigo para emprestar os exemplos que o Thomas utilizou no artigo dele, porém, utilizando redux-saga. Espero retornar o favor ao universo e talvez, ajudar alguém a entender melhor como essa importante tecnologia funciona.

Aviso: Eu irei usar o mesmo cenário do artigo do Thomas, extender os exemplos e (assim espero), criar uma discussão sobre as abordagens. Eu vou assumir que você leitor já tenha um básico conhecimento sobre Promises, React, Redux e, claro, JavaScript!

Primeiros passos

De acordo com Yassine Elouafi, criador do redux-saga:

redux-saga é uma biblioteca que foca em fazer os efeitos colaterais (ex: chamadas assíncronas para buscar dados em uma API, transformações impuras como acessar o cache do navegador, etc) em aplicações React/Redux serem mais fáceis e simples de se criar e manter.

Podemos pensar que é uma biblioteca de utilitários que permite você organizar todas as suas chamadas assíncronas serem distribuídas através de operações baseadas em Sagas e ES6 Function Generators. Se você quer saber mais sobre o padrão e o que é uma Saga, Caitie McCaffrey fez um ótimo trabalho explicando isso nesse vídeo. E se você quiser saber mais sobre ES6 Function Generators, você pode conferir esse vídeo no Egghead (quando eu escrevi esse post, ele era gratuito).

Criando nosso painel de voo

Vamos recriar um cenário que o Thomas usou. Você pode conferir o código final aqui ou o exemplo online aqui.

Imagem por Thomas Burleson

Como podemos ver, a sequência des três chamas a uma API são: getDeparture -> getFlight -> getForecast. Então, nossa camada de chamada ao serviço ficará:

class TravelServiceApi {
static getUser() { // [1]
return new Promise((resolve) => {
setTimeout(() => {
resolve({
email : "somemockemail@email.com",
repository: "http://github.com/username"
});
}, 3000);
});
}
static getDeparture(user) { // [2]
return new Promise((resolve) => {
setTimeout(() => {
resolve({
userID : user.email,
flightID : “AR1973”,
date : “10/27/2016 16:00PM”
});
}, 2500);
});
}
static getForecast(date) { // [3]
return new Promise((resolve) => {
setTimeout(() => {
resolve({
date: date,
forecast: "rain"
});
}, 2000);
});
}
}

Esse é um exemplo simples da API, onde estamos simulados os dados de informação que irão ser usados no nosso cenário. Primeiro precisamos ter um usuário ([1]), em seguida, com as informações do usuário, precisamos buscar os dados do voo ([2]) e sem seguida, vamos buscar os dados do clima para o dia do voo. Só assim, podemos montar nosso painel (meio feio né?), que se parece com:

Os componentes React para esse projeto podem ser encontrados aqui. Existem 3 diferentes componentes, cada um com sua representação no estado global do Redux, que são criadas a partir de 3 reducers. Nossos reducers se parecem com:

const dashboard = (state = {}, action) => {
switch(action.type) {
case ‘FETCH_DASHBOARD_SUCCESS’:
return Object.assign({}, state, action.payload);
default :
return state;
}
};

Nós usamos um reducer diferente para cada painel, com três diferentes cenários, o que permite o componente ter acesso ao pedaço de informação necessária através da função mapStateToProps:

const mapStateToProps =(state) => ({
user : state.user,
dashboard : state.dashboard
});

Agora que a parte do Redux está explicada (é, eu sei que não entrei em detalhe sobre a estrutura Redux), podemos começar a brincar com Sagas!

Entrando no mundo das Sagas

William Deming disse uma vez:

Se você não consegue descrever o que você faz como um processo, então você não sabe o que você está fazendo.

Dito isso, vamos analisar essa próxima etapa passo a passo e entender como podemos trabalhar com Redux Saga.

1. Registrando as Sagas

Vou usar minhas próprias palavras para descrever cada método exposto pela API, se você quiser mais detalhes técnicos, você pode dar uma olhada na documentação nesse link.

Primeiramente, nós precisamos criar nosso saga generator e registrá-lo:

// rootSaga.js
function* rootSaga() {
yield[
fork(loadUser),
takeLatest('LOAD_DASHBOARD', loadDashboardSequenced)
];
}
export default rootSaga;

Redux Saga expõe vários métodos chamados de Effects, e vamos usar vários deles:

  • fork(), realiza uma operação não bloqueante com a função passada
  • take(), pausa as operações até receber uma redux action
  • race(), executa Effects simultaneamente, e cancela todos quando um efeito retorna seu resultado
  • call(), executa uma função. Se essa função retornar uma Promise, ele irá pausar a Saga até a Promise ser resolvida
  • put(), despacha uma redux action
  • select(), executa uma função seletora que irá buscar dados do estado global do Redux
  • takeLatest(), irá executar as operações recebidas, porém, irá retornar apenas o valor da última. Se a mesma operação for enviada mais de uma vez, elas serão ignoradas, exceto a última (ex: click -> loadUser, usuário clica 4 vezes no botão (ele é legal né, quer testar sua app), apenas a função enviada no último click será executada/retornado o valor, as outras serão ignoradas)
  • takeEvery(), irá retornar os valores de todas as operações recebidas

No exemplo acima, nós registramos duas sagas diferentes (loadUser, loadDashboardSequenced), mas, iremos cria-las depois. Analisando o exemplo, nós estamos usando fork e takeLatest, onde takeLatest irá aguardar por uma ação chamada “LOAD_DASHBOARD” ser despachada pelo Redux para ser executada. (mais sobre isso no item 3)

2. Injetando o middleware das Sagas na Redux Store

Quando nós definimos nossa Redux Store, precisamos inicializar o middleware das Sagas:

import createSagaMiddleware from 'redux-saga'import rootSaga from './rootSaga'const sagaMiddleware = createSagaMiddleware();const store = createStore(
rootReducer,
initialState,
compose(
applyMiddleware(sagaMiddleware)
);
);
sagaMiddleware.run(rootSaga);

3. Criando as Sagas

Vamos definir a Saga para loadUser:

function* loadUser() {
try {
// [1]
const user = yield call(getUser);
// [2]
yield put({type: 'FETCH_USER_SUCCESS', payload: user});
} catch(error) {
// [3]
yield put({type: 'FETCH_FAILED', error});
}
}

Podemos ler o código acima dessa maneira:

  • [1] Fazemos a chamada para a função getUser, e guardamos o resultado na variável user
  • [2] Despachamos uma ação chamada FETCH_USER_SUCESS e passamos o valor user recebido na etapa anterior para ser consumido pelos reducers e guardado na store
  • [3] Se algo der errado, despachamos uma ação chamada FETCH_FAILED que avisar nossa aplicação que algo deu errado (aqui podemos mostrar uma mensagem de erro, etc)

Como você pode ver, a leitura é síncrona, mas as operações são assíncronas, só de poder usar yield e armazenar isso em uma variável, já vale a pena.

Agora, vamos criar a próxima Saga:

function* loadDashboardSequenced() {
try {
// [1]
yield take(‘FETCH_USER_SUCCESS’);
// [2]
const user = yield select(state => state.user);
// [3]
const departure = yield call(loadDeparture, user);
// [4]
const flight = yield call(loadFlight, departure.flightID);
const forecast = yield call(loadForecast, departure.date);
// [5]
yield put({
type: ‘FETCH_DASHBOARD_SUCCESS’,
payload: { forecast, flight, departure }
});
} catch (error) {
// [6]
yield put({
type: ‘FETCH_FAILED’,
error: error.message
});
}
}

Vamos ler da seguinte maneira:

  • [1] Esperamos para que uma redux action chamada FECTH_USER_SUCCESS seja despachada. Esse yield ficará na espera até essa ação ocorrer (veja mais sobre Effects no item 1).
  • [2] Selecionamos um valor da redux store. O efeito select recebe uma função que acessa a store. Nós armazenamos o resultado na constante user.
  • [3] Em seguida, executamos uma operação assíncrona para carregar as informações do voo, e passamos o objeto user como parâmetro para o efeito call
  • [4] Assim que a chamada assíncrona do loadDeparture for finalizada, nós executamos loadFlight com o objeto departure recebido na chamada anterior. O mesmo se aplica para a execução da função que busca dados do clima.
  • [5] Finalizando, após todas essas chamadas e funções serem resolvidas, nós usamos o efeito put para despachar uma ação na nossa aplicação, enviando todos os resultados de todas as chamadas assíncronas dessa Saga. Atualizando nosso estado global e enviando as atualizações para nossos reducers.

Como você pode ver, uma Saga é uma coleção de etapas que aguardam uma ação anterior para modificar seu comportamento. Quando finalizadas, todas as informações estão prontas para serem consumidas pela store.

Irado né?

Agora, vamos analisar um caso diferente. Vamos supor que getFlight e getForecast possam ser executadas ao mesmo tempo. Eles não precisam aguardar o resultado de um ou outro, então podemos pensar de outra maneira nesse caso:

Imagem por Thomas Burleson

Sagas não bloqueantes

Para podermos executar duas operações não bloqueantes, nós precisamos modificar nosso código anterior:

function* loadDashboardNonSequenced() {
try {
// Esperando pela redux action
yield take('FETCH_USER_SUCCESS');
// Busca informações do usuário na store
const user = yield select(getUserFromState);
// Busca informações de embarque
const departure = yield call(loadDeparture, user);

// AQUI QUE A MÁGICA ACONTECE 🎉🎉🎉
const [flight, forecast] = yield [
call(loadFlight, departure.flightID),
call(loadForecast, departure.date)
];
// Retornando os valores para nossa aplicação
yield put({
type: 'FETCH_DASHBOARD_2_SUCCESS',
payload: { departure, flight, forecast }
});
} catch(error) {
yield put({type: 'FETCH_FAILED', error: error.message});
}
}

Precisamos registrar o yield como um Array:

const [flight, forecast] = yield [
call(loadFlight, departure.flightID),
call(loadForecast, departure.date)
];

Agora, ambas as operações irão ocorrer em paralelo, mas no final do dia, teremos a certeza de que os dois resultados irão ser apresentado na nossa UI.

Agora, vamos registrar nossa Saga na função principal rootSaga:

function* rootSaga() {
yield[
fork(loadUser),
takeLatest('LOAD_DASHBOARD', loadDashboardSequenced),
takeLatest('LOAD_DASHBOARD2' loadDashboardNonSequenced)
];
}

Desse modo, todos os dados estarão disponíveis de uma vez no final da execução da Saga.

Mas, e se, ao invés de esperar todos os resultados, você quiser atualizar a UI toda vez que um dos resultados forem retornados?

Não se preocupe, tenho tudo resolvido aqui :)

Sagas não sequências e não bloqueantes

E aqui a brincadeira começa a ficar divertida, você pode isolar cada operação em uma Saga e depois combinar todas, ou seja, elas podem trabalhar independente uma da outras. E é exatamente isso que precisamos. Vamos dar uma olhada:

Etapa 1: Nós isolamos as sagas de Clima e Voo. E ambos dependem do resultado da saga de embarque.

// ====================
// Flight Saga
// ====================
function* isolatedFlight() {
try {
/* departure irá pegar o objeto enviado pelo efeito put */
const departure = yield take('FETCH_DEPARTURE_3_SUCCESS');

const flight = yield call(loadFlight, departure.flightID);

yield put({type: 'FETCH_DASHBOARD_3_SUCCESS', payload: {flight}});
} catch (error) {
yield put({type: 'FETCH_FAILED', error: error.message});
}
}
// ====================
// Forecast Saga
// ====================
function* isolatedForecast() {
try {
/* departure irá pegar o objeto enviado pelo efeito put */
const departure = yield take('FETCH_DEPARTURE_3_SUCCESS');
const forecast = yield call(loadForecast, departure.date);

yield put({type: 'FETCH_DASHBOARD_3_SUCCESS', payload: { forecast, }});
} catch(error) {
yield put({type: 'FETCH_FAILED', error: error.message});
}
}

Percebeu algo bem importante aí? Nós iremos arquitetar nossas sagas dessa maneira:

  • Ambos precisam esperar pela mesma redux action chamada FETCH_DEPARTURE_3_SUCCESS ser despachada para começar a sua execução
  • Eles irão receber um valor quando essa ação for despachada. Mais sobre isso a seguir
  • Suas operações assíncronas serão executadas usando o efeito call e ambos irão executar o mesmo evento no final FETCH_DASHBOARD_3_SUCCESS. Mas ambos enviam diferentes dados para a nossa store. Graças ao poder do Redux, nós podemos fazer isso sem modificar nenhum reducer.

Etapa 2: Vamos alterar a Saga de embarque para que ele envie as ações corretas para as duas outras Sagas:

function* loadDashboardNonSequencedNonBlocking() {
try {
// Esperando pela redux action
yield take('FETCH_USER_SUCCESS');
// Busca informações do usuário na store
const user = yield select(getUserFromState);
// Busca informações de embarque
const departure = yield call(loadDeparture, user);
// Despacha uma ação para atualizar a UI
yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { departure, }});
// Despacha a ação necessária para a saga de Clima e Voo começarem...
// Podemos passar um objeto no efeito put
yield put({type: 'FETCH_DEPARTURE3_SUCCESS', departure});
} catch(error) {
yield put({type: 'FETCH_FAILED', error: error.message});
}
}

Nada diferente até chegarmos ao efeito put no final. Nós podemos passar um objeto para a ação despachada e ele será enviado (yielded) para a variável departure nas sagas de voo e clima. Eu amo isso.

Você pode dar uma olhada no demo, e perceber como o terceiro painel carrega o clima antes do voo, porque o timeout dele é maior. Fazemos isso apenas para simular uma chamada lenta.

Em uma aplicação em produção, você provavelmente irá fazer algumas coisas diferentes. Eu só quero mostrar que é possível passar valores para o efeito put.

E testes? Como testar tudo isso?

Você testa seu código… certo?

Sagas são muito fáceis de testar, mas elas são diretamente ligadas as etapas dentro dela, que são geradas pelas ES6 Function Generators. Vamos criar um exemplo (não esqueça que você pode ver todo o código final na pasta Sagas no repositório):

describe('Sequenced Saga', () => {
const saga = loadDashboardSequenced();
let output = null;
it('should take fetch users success', () => {
output = saga.next().value;
let expected = take('FETCH_USER_SUCCESS');
expect(output).toEqual(expected);
});
it('should select the state from store', () => {
output = saga.next().value;
let expected = select(getUserFromState);
expect(output).toEqual(expected);
});
it('should call LoadDeparture with the user obj', (done) => {
output = saga.next(user).value;
let expected = call(loadDeparture, user);
done();
expect(output).toEqual(expected);
});
it('should Load the flight with the flightId', (done) => {
let output = saga.next(departure).value;
let expected = call(loadFlight, departure.flightID);
done();
expect(output).toEqual(expected);
});
it('should load the forecast with the departure date', (done) => {
output = saga.next(flight).value;
let expected = call(loadForecast, departure.date);
done();
expect(output).toEqual(expected);
});
it('should put Fetch dashboard success', (done) => {
output = saga.next(forecast, departure, flight).value;
let expected = put({ type: 'FETCH_DASHBOARD_SUCCESS', payload: { forecast, flight, departure } });
const finished = saga.next().done;
done();
expect(finished).toEqual(true);
expect(output).toEqual(expected);
});
});
  1. Não se esqueça de importar todos os efeitos e funções que você irá testar
  2. Quando você armazenar algo no yield, lembre-se de sempre enviar um objeto simulando (mocking) os dados para a próxima função. Perceba no 3, 4 e 5 teste.
  3. Por trás dos panos, cada generator passa para a próxima linha depois de produzir (yield) um valor quando o próximo método é chamado. Por isso usamos saga.next().value.
  4. Essa sequência é rígida. Se você mudar as etapas na saga, terá que atualizar os testes.

Finalizando

Eu realmente gosto de testar novas tecnologias e no desenvolvimento front-end, achamos coisas novas quase diariamente. É como moléculas, quando é aceitado pela comunidade, parece que todo mundo quer usar isso. Algumas vezes eu acho isso muito válido, mas ainda é importante parar e verificar se realmente precisamos disso.

Eu acho redux-thunk bem simples de se trabalhar e manter. Porém, para aplicações e operações complexas, Redux-Saga faz um ótimo trabalho.

Mais uma vez, eu agradeço ao Thomas por inspirar esse post. Espero que alguém o ache inspirador, assim como eu achei no post dele.

Se você tiver qualquer perguntar, só me mandar um tweet! Fico feliz em ajudar!

Obrigado por ter lido até aqui, se você gostou do post, manda um 💚 e compartilha no Twitter! Valeu! 🙏🏼

Créditos

Async operations using redux-saga, escrito originalmente por Andrés Mijares

Para mais conteúdos de Tecnologia, Design, Ciência nós estamos no https://www.coletividad.org (em inglês)

--

--