Redux-Saga: Gerenciando efeitos e ações

Uma maneira eficiente de gerenciar efeitos no seu sistema!

Isolando efeitos colaterais e deixando suas ações e interface mais puras! https://github.com/redux-saga/redux-saga

Redux é um framework inspirado em Flux que explodiu em popularidade na comunidade React. Ele reduz a complexidade do fluxo de dados através de um padrão unidirecional, uso de um único lugar para guardar o estado da aplicação e funções redutoras puras para atualizar esse estado.

Para mim, algo sempre soava estranho em uma estrutura React+Flux, que é um processo mais complicado, pois coordenação de criadores de ações (action creators) e efeitos colaterais (side-effects) são difíceis de lidar. Soluções usando ciclo de vidas de componentes (componentDidUpdate, componentWillUpdate, etc) e criadores de ações retornando thunks funcionam, mas eles parecem fora do seu devido lugar em certas situações.

Para ilustrar o que quero dizer, vamos olhar em um projeto Timer. O código fonte pode ser encontrado nesse link.

Nosso projeto Timer

Esse projeto permite que usuários iniciem e parem o cronômetro, assim como a opção de resetar ele.

Nós podemos pensar nesse projeto como um máquina finita de estados, com transições entre dois estados: Stopped e Running (Como mostrado no diagrama abaixo). Enquanto o cronômetro estiver no estado running, ele irá atualizar o tempo do timer a cada 1 segundo.

Estados e transições do nosso sistema.

Agora, vamos ver a estrutura inicial do projeto, para então, entendermos como sagas podem nos ajudar a gerenciar os efeitos colaterais usando processos que vivem fora dos criadores de ações e componentes.

Ações

Existem 4 ações no nosso projeto:

  1. START — Transição do cronômetro para o estado running
  2. TICK — Incrementa o cronômetro em cada tick
  3. STOP — Transição do cronômetro para o estado stopped
  4. RESET — Retorna o estado do cronômetro para zero
// actions.js
export default {
start: () => ({ type: 'START' }),
tick: () => ({ type: 'TICK' }),
stop: () => ({ type: 'STOP' }),
reset: () => ({ type: 'RESET' })
};

Modelagem de estado e redutores

O estado do cronômetro consiste de duas propriedades: status e seconds.

type Model = {
status: string;
seconds: number;
}

Onde status pode ser Running ou Stopped, e seconds é o valor acumulado dos segundos contados desde que o cronômetro foi iniciado.

O nosso reducer é implementado da seguinte maneira:

// reducer.js
const INITIAL_STATE = {
status: 'Stopped',
seconds: 0
};

export default (state = INITIAL_STATE, action = null) => {
switch (action.type) {
case 'START':
return { ...state, status: 'Running' };
case 'STOP':
return { ...state, status: 'Stopped' };
case 'TICK':
return { ...state, seconds: state.seconds + 1 };
case 'RESET':
return { ...state, seconds: 0 };
default:
return state;
}
};

A interface do nosso Timer

A interface será bem direta, se deixarmos de fora os efeitos colaterais. Ela renderiza o tempo e estado atual e também invoca as funções callbacks correspondentes, quando o usuário clicar nos botões Reset, Start ou Stop.

export const Timer = ({ start, stop, reset, state }) => (
<div>
<p>
{getFormattedTime(state)} ({state.status})
</p>
<button
disabled={state.status === 'Running'}
onClick={() => reset()}>
Reset
</button>
<button
disabled={state.status === 'Running'}
onClick={() => start()}>
Start
</button>
<button
disabled={state.status === 'Stopped'}
onClick={stop}>
Stop
</button>
</div>
);

Problema: Como lidar com atualizações periódicas?

Po hora, nosso projeto pode realizar a transição entre os estados Running e Stopped, mas nós não temos um mecanismo para agendar atualizações periódicas para nosso cronômetro.

Em um típico projeto Redux+React, temos duas opções para resolver esse problema:

  1. A interface pode chamar o criador de ação periodicamente
  2. O criador de ação pode retornar uma thunk que irá despachar uma outra ação TICK periodicamente

Solução 1: Deixando a interface despachar ações

Para nossa primeira solução, a interface é responsável por esperar que o estado mude entre Stopped para Started e então, começa a despachar atualizações periódicas. Isso significa que precisamos usar um componente para gerenciar o estado:

class Timer extends Component {
componentWillReceiveProps(nextProps) {
const { state: { status: currStatus } } = this.props;
const { state: { status: nextStatus } } = nextProps;

if (currState === 'Stopped' && nextState === 'Running') {
this._startTimer();
} else if (currState === 'Running' && nextState === 'Stopped') {
this._stopTimer();
}
}

_startTimer() {
this._intervalId = setInterval(() => {
this.props.tick();
}, 1000);
}

_stopTimer() {
clearInterval(this._intervalId);
}

// ...
}

Isso funciona. Mas deixa nossa interface com controle de estado e impura. Outro problema é que o componente fica responsável por mais coisas, do que simplesmente renderizar HTML e capturar as interação. Nossa interface agora causa efeitos colaterais, que deixam a aplicação, como um todo, difícil de entender a primeira vista. Isso pode não ser um grande problema para aplicativos pequenos, como nosso exemplo, mas em grandes aplicações, você deve manter efeitos colaterais nas extremidades do sistema.

E nesse caso, como fica as thunks?

Solução 2: Usando Thunks em Criadores de Ações

Um alternativa para nossa solução anterior, seria o uso de thunks nos nossos criadores de ações. Nós podemos mudar a ação start para o seguinte:

export default {
start: () => (
(dispatch, getState) => {
// Realizando a transição para "Running"
dispatch({ type: 'START' });

// Verifica a cada 1 segundo se nós ainda estamos
// no estado "Running". Se sim, despachamos
// o "TICK", se não, paramos o cronômetro.
const intervalId = setInterval(() => {
const { status } = getState();

if (status === 'Running') {
dispatch({ type: 'TICK' });
} else {
clearInterval(intervalId);
}
}, 1000);
}
)
// ...
};

Agora, o criador de ação start, irá despachar a ação START, quando for invocado e, em seguida, uma ação TICK é enviada a cada 1 segundo, enquanto o estado do cronômetro seja Running.

O problema que eu tenho com essa solução, é que o criador de ação está responsável por muita coisa. Também é difícil de testar essa ação, pois não está retornando apenas dados.

Uma solução mais elegante: Utilizando Sagas para gerenciar nosso sistema

O projeto redux-saga transforma os efeitos colaterais em artefatos chamados de Efeitos (Effects). Efeitos são criados por outros artefatos chamados de Sagas. O conceito de sagas, até onde eu sei, vem do mundo de CQRS e Event Sourcing. Existem alguns debates sobre o que sagas realmente são, mas você pode imaginá-las como processos com vida longa, que interagem com o sistema através de:

  1. Reagindo a ações despachadas no sistema
  2. Despachando novas ações no sistema
  3. Podem ser despertadas/acordadas usando um mecanismo interno, sem a necessidade de outras ações serem despachadas (ex: acordar a cada intervalo de 1 minuto)

Em redux-saga, uma saga é uma função Generator, que pode ser executada interminavelmente dentro do sistema. Ela pode ser acordada quando uma ação específica é despachada. Ela pode despachar ações adicionais e tem acesso ao estado da aplicação.

Por exemplo, se quisermos despachar periódicos eventos TICK enquanto o cronômetro estiver em Running, podemos fazer o seguinte:

function* runTimer(getState) {
// `sagasMiddleware` irá executar essa função Generator

// a saga será acordada quando o evento
// `START` for despachado no sistema
while (yield take('START')) {
while (true) {
// estamos usando o efeito `call`, singifica
// que o efeito não será executado nessa
// linha, tornando mais fácil criar testes
yield call(wait, ONE_SECOND);

// verificamos se o cronômetro ainda está
// em "Running" se sim, despachamos a ação "TICK"
if (getState().status === 'Running') {
yield put(actions.tick());
} else {
// caso contrário, quebramos
// o loop `while(true)` e colocamos
// a saga em um estado inativo/dormente
break;
}
}
}
}

Como você pode ver, uma saga usa os controles de fluxo normais do JavaScript para coordenar efeitos colaterais e criadores de ações. A função take irá acordar a saga quando a ação START for despachada. Em seguida, a função put retorna um novo TICK para as ações do sistema. A função call, nos permite modelar um efeito de espera, em uma estrutura que não faz com que ele seja executado — parecido com um Task.

Ao usarmos sagas, somos capazes de deixar nossa interface e criadores de ações como funções puras. Também nos permite modelar transições de estado usando construções conhecidos do JavaScript.

Finalizando

Sagas são uma maneira eficiente de gerenciar efeitos colaterais em um sistema. Funcionando muito bem quando temos processos de longo período e que coordene múltiplas ações e efeitos nesse tempo.

Sagas reagem a ações, assim como mecanismos internos (ex: efeitos baseados em períodos de tempo). Eles são extremamente úteis quando você precisa gerenciar efeitos colaterais fora do fluxo de trabalho normal do Flux. Por exemplo, uma interação de usuário pode levar a futuras ações que não necessitam da interação do usuário.

Por último, sempre que você puder modelar sua solução como uma máquina de estados finitos, vale a pena tentar utilizar as sagas.

O código fonte do nosso projeto Timer está nesse repositório.

E você, já tentou utilizar Sagas? Compartilhe sua experiência abaixo!

⭐️ Créditos