Redux + Redux Saga: gerenciamento de Requisições React | Zup
Redux + Redux Saga: gerenciamento de Requisições React
• 3 jul 2019
Se seu projeto já usa Redux + Redux Saga, continue a leitura, essas bibliotecas podem simplificar muito seu código! Caso contrário, dê uma olhada em features novas do React como hooks, providers e contextos. Em 2021, para a maioria dos projetos, uma combinação desses três e uma boa arquitetura é melhor que usar a combinação Redux + Redux Saga.
Autores: Tiago Peres França e Raphael de Souza.
Agradecimentos aos demais autores da biblioteca e revisores deste texto: Isac, Tiago Peres França e Raphael de Souza. ❤️
Nós aqui da Zup, geralmente usamos um conjunto de utilitários quando precisamos gerenciar requisições no React. Recentemente, decidimos compartilhar essas ferramentas através de dois repositórios: redux-resource e redux-action-cache. Neste artigo, falaremos sobre o primeiro, que é responsável por criar quase tudo que precisamos para controlar nossas requisições através das ações e estado do redux.
Sejamos sinceros, criar tipos de ações (constantes), action creators, reducers e sagas para todas as requisições é bem chato. Isso leva tempo e, às vezes, fica difícil de manter. Quando usamos serviços REST, todo GET, PUT, POST ou DELETE é basicamente a mesma coisa. Por que estamos recriando o código toda vez que precisamos chamar um novo serviço?
E se também tratarmos nossas requisições como recursos REST no front-end? Não poderíamos então usar o mesmo tratamento para todas as requisições e evitar repetição de código? É exatamente essa a intenção da nossa biblioteca redux-resource.
Aplicação usando a biblioteca da Zup
Este artigo apresenta uma aplicação simples criada com o redux-resource. A aplicação se trata de uma loja online de filmes que permite ao usuário verificar seu saldo atual, ver o catálogo de filmes e fazer compras. O foco aqui é em como gerenciar as requisições, portanto não entraremos em detalhes quanto aos estilos ou outros componentes react.
O projeto demo com o código completo pode ser encontrado aqui. Você pode seguir as instruções no readme do projeto para executá-lo. As imagens a seguir ilustram o funcionamento da aplicação:
Tela 1: home do projeto com o cartaz de vários filmes em um fundo escuro.
Na Tela 1, o usuário confere o saldo e o catálogo de filmes disponíveis para compra.
Tela 2: detalhes do filme "Vingadores: Ultimato (2019)
Tela 3: tela de pagamento
A Tela 3 é responsável por efetuar o pagamento e finalizar a compra.
Escrevendo a aplicação na API REST
A API REST que estamos utilizando para construir a aplicação consiste dos seguintes serviços:
GET /profile: recupera o perfil do usuário.
PUT /profile: atualiza o perfil do usuário de acordo com os dados passados no corpo da requisição.
‍GET /catalog: recupera a lista de filmes disponíveis.
‍GET /wallet: recupera a carteira do usuário. A carteira consiste de um objeto com duas propriedades: balance e cards. "balance" é um número representando o saldo e "cards" é a lista de cartões de crédito.
‍POST /wallet: adiciona um novo cartão de crédito à carteira.
‍DELETE /wallet: remove um cartão de crédito da carteira.
POST /order: cria uma compra.
Atenção: apesar das requisições http serem GET, POST, PUT ou DELETE, as operações definidas no redux-resource são load, create, update e remove.
Podemos identificar três recursos ao analisar as duas primeiras telas: o perfil do usuário, o saldo (carteira) e o catálogo de filmes. A primeira coisa que precisamos fazer é criar as funções da API. Recomendamos usar a biblioteca axios para isso.
Agora que definimos a interface da API, podemos criar os recursos:
Simples assim! Todo recurso pode ter as operações load, update, create ou remove. Neste exemplo, usamos as operações update profile, create wallet e remove wallet apenas para ilustrar como elas poderiam ser usadas. De fato, não faremos chamadas a nenhuma delas no decorrer do código.
A função createResource retorna um objeto com as chaves:
types: os tipos das ações (strings).
actions: os actions creators (funções que criam ações).
reducers: os reducers a serem providenciados ao redux.
sagas: as sagas a serem providenciadas ao redux-saga.
Utilizamos o valor de retorno de createResource para criar nossa store e disparar ações. O código seguinte mostra como criar o store do redux com os recursos que criamos.
Acima, combineReducers é uma função típica do redux, enquanto createEffects é uma função utilitária da nossa biblioteca responsável por criar os efeitos do redux-saga, ela recebe um objeto onde as chaves correspondem ao tipo das ações e os valores correspondem às funções generator.
Para mais detalhes em relação à função createEffects, por favor leia nossa documentação.
Agora que configuramos os recursos e o store do redux, podemos começar a usá-los! Nós dividimos a Tela 1 e a Tela 2 em três containers:
Header: responsável pelo cabeçalho, compartilhado por todas as telas.
Home: responsável pelo conteúdo da Tela 1.
Movie: responsável pelo conteúdo da Tela 2.
Abaixo, o código do container Header:
Dentro da parte componentDidMount do ciclo do react, disparamos a operação load para os recursos "profile" e "wallet". Na função render, checamos o estado dos recursos e, baseado neles, decidimos o que mostrar na tela. Os nomes isPristine, isLoading e hasLoadError são funções utilitárias providenciadas pela nossa biblioteca, mas se elas não te agradam, você pode também verificar o estado da operação diretamente:
Existem quatro estados possíveis para uma operação de um recurso: "pristine", "pending", "success" e "error". Primeiro verificamos "pristine", pois uma renderização é feita antes das operações de load serem disparadas e não queremos renderizar nada nesse caso.
As respostas para nossas requisições são retornadas no campo "data" dos recursos. Quando definimos a API (primeiro código apresentado neste artigo), dissemos ao axios para retornar apenas o payload na resposta, portanto, no nosso caso, "data" contém exatamente o payload das respostas retornadas pelo servidor.
Podemos usar a mesma ideia para construir os demais componentes.
Código para o container Home:
Container Movie:
No container Movie.js, não estamos interessados no catálogo completo, mas apenas no filme com id igual ao parâmetro passado na URL. Portanto, selecionamos o filme que queremos do catálogo usando findMovieById. Em aplicações reais, seria preferível utilizar o reselect para memoizar o filme, já que rodar um findBy dentro do método render pode ser muito caro.
Tela 3: interface de pagamento
Agora vamos implementar a terceira tela: a interface de pagamento. Para isso, vamos usar a operação "create" e algumas outras ações para controlar seu comportamento. Nos vemos lá!
Interessados em hooks, typescript e cache? O projeto demo-typescript, em nosso repositório de demos, implementa esse mesmo projeto usando typescript ao invés de javascript. O projeto demo-hooks, além de usar typescript, usa hooks e ainda configura um cache inteligente para a aplicação (spoiler alert: hooks rocks!). Sobre o cache, ainda vamos falar bastante sobre isso ao final deste artigo, aguarde.
Você pode ter notado também a natureza estática dos recursos que criamos. Se chamarmos o load de um recurso duas vezes, por exemplo, o conteúdo atual seria substituído pelo novo. Esse comportamento funciona muito bem para a maioria das situações, mas e se quiséssemos recuperar dois filmes (através de uma URL do tipo "/movies/{id}") e exibi-los lado a lado?
A biblioteca redux-resource também disponibiliza a função createDynamicResource exatamente para esses casos. Se precisar usá-la, dê uma olhada na nossa documentação e no demo demo-dynamic-resource.
Você é um profissional da área e quer fazer a diferença junto com uma equipe fora da curva? Então acompanhe todas as nossas vagas em desenvolvimento.
Sobre a terceira tela
A terceira tela é um pouco mais complexa, por isso deixamos ela por último.
No container Payment, precisamos carregar alguns recursos e ainda criar uma order. Para começar, podemos usar a mesma estratégias que usamos anteriormente:
Os componentes Loading, LoadingError, OrderProgress, OrderSuccess e PaymentMethod, no código acima, não são importantes para o entendimento do comportamento da biblioteca. Mas, se você deseja verificá-los, eles podem ser encontrados aqui.
Só pra lembrar, nós usamos isCreating, hasCreateError e hasCreateSuccess para verificar o status da operação create. Estas são algumas funções utilitárias disponíveis na biblioteca e você pode encontrar uma lista de todas elas aqui.
No componente Summary, nós chamamos a função createOrder que dispara a ação para realizar a operação create do recurso order. Note que aqui nós passamos para a função o objeto order que desejamos criar.
Ótimo! Se você executar o código agora, quase tudo estará funcionando como esperado, mas existem dois problemas que precisamos corrigir.
Primeiro problema: resetar o estado do redux do recurso
Uma vez que order é criado, "order.create.status" continuará com o mesmo valor no estado do redux, sendo success ou error. Então, se voltarmos para a tela principal (home) e tentarmos comprar um filme outra vez, nós não conseguiremos, pois encontraremos a página de pagamento nos mostrando uma mensagem de erro ou sucesso, dependendo do resultado da última order criada.
Para corrigir isso, precisamos reiniciar o estado para a operação create do nosso recurso antes de desmontar o componente (ou sempre que montarmos ele). Para alcançar isso, nós podemos usar a action creator resetOrderStatus. Fácil assim!
Observação: Você pode encontrar uma lista completa de todas as actions creators disponíveis em um recurso visitando a nossa documentação.
Agora, se você tentar comprar um novo filme, funcionará como queremos! Mas ainda existe um outro problema para solucionar…
Segundo problema: atualizar o valor do saldo no estado do redux
Se você prestar atenção à mensagem de sucesso da operação order, vai perceber que há algo errado ali. O texto diz "Thank you for buying with us. Your current balance is x" e olhe só, o valor de x continua o mesmo. Assim como também o valor no header não foi atualizado. Nada bom! Não podemos permitir que os usuários comprem coisas para sempre.
A forma como definimos nossos recursos apenas diz a eles o que fazer quando uma ação é disparada, e eles irão lidar com os próprios dados. Mas, nós podemos encontrar situações onde uma ação deve também impactar outro recurso. Esse é um deles. Quando uma compra é paga, em outras palavras, quando um recurso order é criado, o valor do saldo pode ter sido alterado.
Por essa razão, nós também precisamos atualizar o recurso wallet. Para fazer isso, podemos usar uma das seguintes abordagens:
Atualizar o estado do redux manualmente, subtraindo o preço do filme do saldo em wallet.balance.
Carregar o recurso wallet novamente.
Nós escolhemos a segunda abordagem para corrigir esse problema. Nós faremos isso definindo que após cada compra feita com sucesso, o recurso wallet deverá ser carregado novamente. Isso é bem simples de fazer.
Quando declaramos nossos recursos, podemos passar um terceiro parâmetro, que são generator functions para serem executadas após a operação ser realizada com sucesso. Note que esses parâmetros devem ser generator functions pois serão integradas como parte do saga.
Veja no código abaixo como podemos alterar a definição do recurso order para implementar essa funcionalidade.
Feito! Agora, toda vez que comprarmos algo, a ação para carregar o recurso wallet será disparada, o que atualizará o valor do saldo em todos os lugares da aplicação.
As ações de update e remove, disponíveis em nossos recursos, funcionam de forma similar à create. Você pode acessar a documentação completa dos recursos e ver por si mesmo.
Para implementar a primeira sugestão dada (atualizar o estado do redux manualmente, subtraindo o preço do filme do saldo em wallet.balance), nós poderíamos usar o primeiro parâmetro passado para a função de callback de sucesso. Para mais informações, por favor, confira a nossa documentação.
Por enquanto é isso! A aplicação demo está implementada e nós lidamos com as requisições de uma forma bem rápida e organizada. Obrigado por nos seguir até este momento. Adoraríamos ouvir suas opiniões, e se você tiver ideias, sinta-se livre para contribuir com nosso repositório.
Motivação para criação da biblioteca
Nós que trabalhamos com front-end, estamos diretamente relacionados à boa experiência do usuário com nossas aplicações. Cada micro interação, ajustes e melhorias que fazemos, são direcionadas para melhor a experiência do usuário.
Na Zup, essa preocupação se tornou evidente quando percebemos em um dos nossos projetos que, apesar de termos uma aplicação funcional e um bom feedback do cliente, a experiência do usuário poderia ser melhorada com a redução no número de "loadings" através de um controle mais inteligente das requisições.
Foi nessa linha de pensamento que resolvemos criar a biblioteca que é base dessa última parte do artigo. A seguir, iremos explicar como o uso da biblioteca melhorou consideravelmente vários aspectos para nossos clientes e para nós desenvolvedores.
Melhorando a aplicação demo
Voltando à nossa aplicação demo, podemos dizer que ela já foi implementada e funciona muito bem, mas ainda podemos fazer uma melhoria. Se você abrir o painel Network, nas ferramentas de desenvolvedor do Chrome, verá que alguns recursos são chamados, na maioria das vezes, desnecessariamente.
A imagem abaixo mostra o estado do painel Network na tela de catálogo, após a compra de um filme.
Painel Network após a compra de um filme e exibição do catálogo (sem cache).
Veja que a URL "/catalog" foi chamada três vezes e a URL "/wallet" foi chamada duas vezes.
Como as operações de load acontecem no componentDidMount de cada componente, sempre que esses componentes são mostrados na tela, as requisições são disparadas. Em várias aplicações nos deparamos com situações desse tipo, em que uma página precisa de um recurso que já foi carregado por outra ou então o componente está sendo mostrado pela segunda vez e não precisa refazer a busca.
Poderíamos carregar os recursos na primeira página do fluxo e não carregar no restante, mas essa é uma péssima ideia. E se o usuário entrar diretamente pela URL? Além disso, ao inicializar cada container, teríamos que saber, a priori, se os recursos já foram carregados ou não, o que complicaria nosso código.
Outra solução seria verificar todas as vezes se o recurso já foi carregado antes de carregá-lo novamente. Mas em algumas situações, é realmente necessário recarregar um conteúdo, portanto, acabaríamos criando uma série de "ifs" que poderiam se tornar bem complexos no futuro.
A solução que queremos é uma que seja capaz de lembrar os recursos que já foram carregados e esquecê-los nas horas certas, mas sem ter que adicionar complexidade ao código dos nossos containers. Nos containers, queremos continuar com a simplicidade de disparar a ação load de um recurso sempre que precisamos utilizá-lo, sem se preocupar se ele já foi carregado ou não.
Ou seja, precisamos de um sistema inteligente de cache que pode ser configurado de fora dos containers. Para esse sistema demos o nome de redux-action-cache.
Como todas as nossas ações de fetch são gerenciadas pelo redux-saga, surgiu a ideia de interceptar as ações do redux e tomar decisões de acordo com a necessidade.
redux-action-cache
O redux-action-cache é um middleware que permite cachear ações do redux, ou seja, quando uma ação é disparada, antes que ela possa prosseguir, ela passa por nosso middleware que age como um gateway, dizendo se a ação será ignorada ou se ela pode prosseguir para os demais middlewares e reducers.
Uma ação é ignorada sempre que existe um cache válido para ela. A aplicação começa com um cache vazio, assim que uma ação é disparada, caso exista uma regra de cache para ela, um cache é criado, de forma que, caso ela seja disparada novamente, será ignorada até que esse cache não seja mais válido.
Para configurar o redux-action-cache é bem simples, basta definirmos as regras para nossa aplicação e adicionar o middleware ao redux. Atenção: nosso middleware precisa ser o primeiro a ser definido para que ele funcione como um gateway! Não adianta ignorarmos uma ação depois que ela já tenha passado pelo redux-saga, por exemplo.
Voltando para a nossa aplicação de loja de filmes digitais, podemos extrair as seguintes regras de cache:
Cachear todas as ações onde "type" termina com "/LOAD". Esses tipos de ações são definidos pelo redux-resource. São o valor do campo "types" no resultado da função "createResource". Veja aqui uma lista com todos os tipos.
As ações de load não devem ser cacheadas se acontecer algum erro durante a requisição. Ou seja, se dispararmos um "/LOAD" após um "/LOAD_ERROR", "/LOAD" deve ser processado pelo redux, independente de ter sido cacheado anteriormente. Em outras palavras, todo "/LOAD_ERROR" deve invalidar o cache de seu "/LOAD" correspondente.
A ação load do recurso "wallet" deve ser, obrigatoriamente, recarregada após uma compra ser efetuada com sucesso. Ou seja, uma ação com tipo "ORDER/CREATE_SUCCESS" deve invalidar o cache da ação "WALLET/LOAD".
Tendo definido as regras de cache da nossa aplicação, basta escrevê-las no formato esperado pela biblioteca redux-action-cache.
No código acima, em "include", definimos um array com todos os tipos de ações que devem ser cacheadas. Outra maneira de atingir o mesmo resultado seria utilizando uma regex. Veja o exemplo abaixo:
Nas invalidações, precisamos utilizar regex e grupos de captura para definir que toda ação que segue o formato "(x)/LOAD_ERROR" deve invalidar a ação "(x)/LOAD". Ao invés de usar regex, poderíamos ter definido todas as ações de erro e todas as ações de load, mas o código ficaria bem mais longo.
Ao criar o store do redux, basta incluirmos nosso middleware:
Agora se executarmos a aplicação novamente, comprando um filme e retornando ao catálogo, veremos que são feitas apenas as requisições necessárias para o funcionamento da aplicação.
Painel Network após a compra de um filme e exibição do catálogo (com cache).
Agora, o catálogo é chamado apenas uma vez! Apesar de vários containers utilizá-lo, o catálogo é um só, não precisamos carregá-lo o tempo todo. A "wallet" continua sendo chamada duas vezes, pois definimos que seu cache deve ser invalidado sempre que uma compra é feita.
A economia é pouca nesta aplicação que é simples, mas em situações reais, o redux-action-cache faz uma diferença enorme e isso com pouquíssimas linhas de código!
No começo desse nosso papo, falamos sobre a importância de profissionais de desenvolvimento front-end se importarem de forma minimalista com a experiência que proporcionam ao usuário. Essa abordagem do redux-action-cache nos permitiu melhorar a comunicação do usuário com a aplicação, diminuindo a quantidade de requisições feitas ao servidor, o que trouxe diversos benefícios, como a redução no número de loadings da aplicação e redução na carga do servidor.
Aqui utilizamos uma configuração simples para o cache, mas a biblioteca oferece várias opções de customização como: validade máxima, persistência e cacheamento baseado em outras propriedades que não sejam o tipo da ação.
Para mais informações, consulte aqui nossa documentação.
Pronto! Chegamos ao fim.
No decorrer desse artigo criamos uma aplicação com as bibliotecas redux-resource e redux-action-cache. Através dessas bibliotecas, foi possível simplificar o código no que diz respeito ao gerenciamento de requisições.
Além disso, criamos um cache inteligente de fácil configuração.
Obrigado pela leitura e se tiver dúvidas ou sugestões, não hesite em utilizar o GitHub para criar issues ou submeter pull requests.