Como funciona o Redux Saga?

Introdução

Você acha confusa a sintaxe dos generators e gostaria de compreender melhor o funcionamento interno da biblioteca Redux Saga? Neste artigo irei abordar como podemos utilizar a sintaxe de generators no JavaScript para simular uma sintaxe síncrona em atividades assíncronas, que é exatamente o que o Redux Saga faz.

Se você ainda não leu meu artigo sobre Iterators e Generators no JavaScript, recomendo a leitura, pois lá abordo pontos importantes para entender o funcionamento dos generators, e será um conhecimento essencial para que você aproveite ao máximo este artigo.

Para que serve o Redux Saga?

Foge do escopo deste artigo explicar como utilizar o Redux Saga, mas de forma simples é uma middleware do Redux para gerenciar side-effects. Side-effects se referem a tudo aquilo que pode alterar o estado de uma aplicação fora do escopo dela, como por exemplo requisições assíncronas, leituras e escritas no localStorage, etc.

O Redux Saga, por ser uma middleware do Redux, consegue “escutar” actions específicas, realizar tarefas de acordo com elas (os side-effects) e disparar actions como respostas.

O que este artigo não vai cobrir?

Não entrarei em detalhes profundos do funcionamento do Redux Saga como middleware, mas sim sobre como podemos através dos generators realizar operações assíncronas.

Nota sobre trechos de código

Quando eu usar ... antes e/ou depois de um código estou me referindo a trechos de algum código exibido anteriormente.

Exemplo:

...
const value = yield call(fn, args);
...

Executando Promises com uso de Generators

Observe o código abaixo:

function* tasks() {
yield delay(1000);
    const user = yield call(getUser, 1);
console.log(user) // { id: 1, name: 'John'}
}
process(tasks); 

Nosso objetivo é fazer com que ele funcione ao final do artigo.

No exemplo acima:

  • tasks é um generator
  • delay é uma Promise que resolve após o tempo especificado
  • call é uma função especial que vamos definir
  • getUser é uma Promise que simula uma requisição assíncrona
  • process é uma função que vamos definir e será responsável por executar todas as operações no generator

Nos generators cada chamada de yield pausa o generator até a próxima chamada do método next do iterator retornado, o que nos levara até a próxima chamada de yield.

Como nosso generator está sempre retornando uma Promise (não necessariamente precisa ser uma Promise como veremos depois), a ideia é criar uma função que processe de forma sequencial os valores retornados por yield.

Por exemplo, na primeira chamada de yield:

function* tasks() {
yield delay(1000);
...
}

Após a Promise delay(1000) ser resolvida, a execução continua até o próximo yield:

function* tasks() {
...
const user = yield call(getUser, 1);
console.log(user) // { id: 1, name: 'John'}
}

call(getUser, 1) será resolvido e continuara a execução.

Passo a passo para a implementação

Definindo as funções delay, getUser e call

delay será uma high order function que resolve uma Promise após um número determinado de milissegundos (ms).

function delay(ms) {
return function () {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
}
}

getUser será uma Promise que simula uma requisição externa a uma API, com um delay de 1000ms.

function getUser(id) {
const users = {
1: {id: 1, name: 'John'},
2: {id: 2, name: 'Charles'},
3: {id: 3, name: 'Hebert'}
};
    return new Promise((resolve, reject) => {
setTimeout(() => {
if(users[id]) {
resolve(users[id]);
} else {
reject('User not found!');
}
}, 1000);
});
}

call também será uma high order function responsável por executar uma função com os argumentos passados.

Mais a frente você entenderá o por que precisamos dela.

function call(fn, ...args) {
return function() {
return fn(...args);
}
}

Definindo a função process

process será responsável por executar todas as Promises.

Vamos ver isso com mais detalhes:

function process(task) {
const taskIterator = task();
...
// restante da implementação
}

Antes de continuar vale lembrar como funciona o iterator retornado por um generator.

Observe no exemplo a seguir:

function* myGenerator() {
yield 1;
yield 2;
}
const myIterator = myGenerator();
myIterator.next(); // { value: 1, done: false }
myIterator.next(); // { value: 2, done: false }
myIterator.next(); // { value: undefined, done: true }

Note que a cada chamada de next é retornado um objeto contendo o valor retornado por yield e um boleano doneindicando se já chegamos ao fim ou não.

E se quiséssemos imprimir todos esse valores de uma só vez?

Poderíamos utilizar uma função recursiva para fazer isso, como segue abaixo:

function* myGenerator() {
yield 1;
yield 2;
}
const myIterator = myGenerator();
function printAll(iterator) {
const { value, done } = iterator.next();
    if(!done) {
console.log(value);
printAll(iterator);
}
}
printAll(myIterator);
// 1
// 2

Para implementação da função process a ideia é exatamente a mesma:

function process(task) {
const taskIterator = task();
resolveAndCallNext(taskIterator);
}

Precisaremos de uma função auxiliar que será responsável por percorrer taskIterator, resolvendo as Promisesquando houver e indo para o próximo yield.

Definindo a função resolveAndCallNext

A ideia desse função recursiva é percorrer cada item no iterator, verificar o valor retornado e tratar de acordo com o tipo. Se for uma Promise por exemplo, ela será resolvida e o processo se inicia novamente, até que se chegue ao fim da iteração.

Veja abaixo os comentários com detalhes de implementação:

function resolveAndCallNext(iter, nextArg) {
// "value" contém o valor passado para yield
// "done" vai indicar se chegamos ao fim ou não
// "nextArg" valor que será retornado na execução de yield
const { value, done } = iter.next(nextArg);
    // enquanto não chegar ao fim
if(!done) {
if(typeof value === 'function') {
// se "value" for uma função vamos executá-la
const result = value();
            // se o resultado da execução de "value" for uma 
// Promise, vamos resolvê-la
if(result instanceof Promise) {
result.then(resolved => {
// chamamos novamente a função
// passando "resolved"
// como retorno da execução de yield
resolveAndCallNext(iter, resolved);
}).catch(error => {
// se houver algum erro disparamos uma
// exceção no iterador, isso pode ser capturado
// por um bloco try/catch dentro do generator
iter.throw(error);
});
} else {
// se o resultado da execução de "value"
// não for uma Promise, simplesmente passamos
// esse valor para o retorno da execução de yield
// fazendo outra chamada recursiva
resolveAndCallNext(iter, result);
}
} else {
// se "value" não for uma função, fazemos outra chamada
// recursiva passando "value" como retorno da execução
// de yield
resolveAndCallNext(iter, value);
}
}
}

E assim teremos nossa função process finalizada:

function process(task) {
const taskIterator = task();
resolveAndCallNext(taskIterator);
}

Por fim vejamos um exemplo mais completo:

function* tasks() {
yield delay(1000);
console.log('after 1000ms');
    const user1 = yield call(getUser, 1);
console.log(user1);

const user2 = yield call(getUser, 2);
console.log(user2);

const user3 = yield call(getUser, 3);
console.log(user3);
    const yieldedValue = yield 10;
console.log('yieldedValue', yieldedValue);
    const syncCallResult = yield call(Math.pow, 2, 3);
console.log('syncCallResult', syncCallResult);
    try {
const user4 = yield call(getUser, 4);
console.log(user4);
} catch(e) {
throw new Error('Error loading user 4');
}
}
process(tasks);

Note que essa implementação não funciona apenas com Promises mas também com outros valores:

    ...
const yieldedValue = yield 10;
console.log('yieldedValue', yieldedValue); // 10
    const syncCallResult = yield call(Math.pow, 2, 3);
console.log('syncCallResult', syncCallResult); // 8
...

Também é possível tratar exceções, como no trecho abaixo:

    ...
try {
const user4 = yield call(getUser, 4);
console.log(user4);
} catch(e) {
throw new Error('Error loading user 4');
}
...

Você pode ver um exemplo funcionando aqui.

Finalizando

Essa foi nossa mini-implementação (bem mini) do Redux Saga. Espero que tenha ajudado você a entender melhor o seu funcionamento interno.

Esse também foi um exemplo do que podemos construir utilizando generators no JavaScript. Podemos ver que suas aplicações não se resumem apenas a ideia de criar objetos iteráveis.

Não deixe de comentar caso tenha dúvidas, sugestões ou correções.

Obrigado e até a próxima!