Busca de dados obsoletos durante a revalidação com ganchos do React: um guia
Publicados: 2022-03-11Aproveitar a extensão HTTP Cache-Control
obsoleta enquanto revalida é uma técnica popular. Envolve o uso de ativos em cache (obsoletos) se forem encontrados no cache e, em seguida, revalidar o cache e atualizá-lo com uma versão mais recente do ativo, se necessário. Daí o nome stale-while-revalidate
.
Como funciona stale-while-revalidate
Quando uma solicitação é enviada pela primeira vez, ela é armazenada em cache pelo navegador. Então, quando a mesma solicitação é enviada pela segunda vez, o cache é verificado primeiro. Se o cache dessa solicitação estiver disponível e válido, o cache será retornado como resposta. Em seguida, o cache é verificado quanto à obsolescência e é atualizado se encontrado obsoleto. A obsolescência de um cache é determinada pelo valor max-age
presente no cabeçalho Cache-Control
junto com stale-while-revalidate
.
Isso permite carregamentos de página rápidos, pois os ativos armazenados em cache não estão mais no caminho crítico. Eles são carregados instantaneamente. Além disso, como os desenvolvedores controlam a frequência com que o cache é usado e atualizado, eles podem impedir que os navegadores mostrem dados excessivamente desatualizados aos usuários.
Os leitores podem estar pensando que, se eles podem fazer com que o servidor use certos cabeçalhos em suas respostas e deixe o navegador levá-lo a partir daí, então qual é a necessidade de usar React e Hooks para armazenamento em cache?
Acontece que a abordagem de servidor e navegador só funciona bem quando queremos armazenar em cache o conteúdo estático. Que tal usar stale-while-revalidate
para uma API dinâmica? É difícil encontrar bons valores para max-age
e stale-while-revalidate
nesse caso. Muitas vezes, invalidar o cache e buscar uma nova resposta toda vez que uma solicitação é enviada será a melhor opção. Isso efetivamente significa que não há armazenamento em cache. Mas com React e Hooks, podemos fazer melhor.
stale-while-revalidate
para a API
Percebemos que o stale-while-revalidate
do HTTP não funciona bem com solicitações dinâmicas, como chamadas de API.
Mesmo se o usarmos, o navegador retornará o cache ou a nova resposta, não ambos. Isso não vai bem com uma solicitação de API, pois gostaríamos de respostas novas sempre que uma solicitação for enviada. No entanto, esperar por novas respostas atrasa a usabilidade significativa do aplicativo.
Então, o que fazemos?
Implementamos um mecanismo de cache personalizado. Dentro disso, descobrimos uma maneira de retornar o cache e a nova resposta. Na interface do usuário, a resposta em cache é substituída por uma nova resposta quando disponível. Assim ficaria a lógica:
- Quando uma solicitação é enviada ao endpoint do servidor de API pela primeira vez, armazene a resposta em cache e, em seguida, retorne-a.
- Na próxima vez que a mesma solicitação de API acontecer, use a resposta em cache imediatamente.
- Em seguida, envie a solicitação de forma assíncrona para buscar uma nova resposta. Quando a resposta chegar, propague as alterações de forma assíncrona para a interface do usuário e atualize o cache.
Essa abordagem permite atualizações instantâneas da interface do usuário — porque todas as solicitações de API são armazenadas em cache — mas também eventual correção na interface do usuário, pois os dados de resposta atualizados são exibidos assim que estiverem disponíveis.
Neste tutorial, veremos uma abordagem passo a passo sobre como implementar isso. Chamaremos essa abordagem de stale-while-refresh, pois a interface do usuário é realmente atualizada quando obtém a nova resposta.
Preparações: A API
Para iniciar este tutorial, primeiro precisaremos de uma API de onde buscamos dados. Felizmente, há uma tonelada de serviços de API simulados disponíveis. Para este tutorial, usaremos reqres.in.
Os dados que buscamos são uma lista de usuários com um parâmetro de consulta de page
. É assim que o código de busca se parece:
fetch("https://reqres.in/api/users?page=2") .then(res => res.json()) .then(json => { console.log(json); });
A execução deste código nos dá a seguinte saída. Aqui está uma versão não repetitiva:
{ page: 2, per_page: 6, total: 12, total_pages: 2, data: [ { id: 7, email: "[email protected]", first_name: "Michael", last_name: "Lawson", avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/follettkyle/128.jpg" }, // 5 more items ] }
Você pode ver que isso é como uma API real. Temos paginação na resposta. O parâmetro de consulta de page
é responsável por alterar a página, e temos um total de duas páginas no conjunto de dados.
Usando a API em um aplicativo React
Vamos ver como usamos a API em um React App. Quando soubermos como fazê-lo, descobriremos a parte do cache. Nós estaremos usando uma classe para criar nosso componente. Aqui está o código:
import React from "react"; import PropTypes from "prop-types"; export default class Component extends React.Component { state = { users: [] }; componentDidMount() { this.load(); } load() { fetch(`https://reqres.in/api/users?page=${this.props.page}`) .then(res => res.json()) .then(json => { this.setState({ users: json.data }); }); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { const users = this.state.users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{users}</div>; } } Component.propTypes = { page: PropTypes.number.isRequired };
Observe que estamos obtendo o valor da page
por meio de props
, como geralmente acontece em aplicativos do mundo real. Além disso, temos uma função componentDidUpdate
, que busca novamente os dados da API toda vez que this.props.page
alterado.
Neste ponto, ele mostra uma lista de seis usuários porque a API retorna seis itens por página:
Adicionando cache obsoleto durante a atualização
Se quisermos adicionar o cache obsoleto durante a atualização, precisamos atualizar nossa lógica de aplicativo para:
- Armazene em cache a resposta de uma solicitação exclusivamente depois que ela for buscada pela primeira vez.
- Retorne a resposta em cache instantaneamente se o cache de uma solicitação for encontrado. Em seguida, envie a solicitação e retorne a nova resposta de forma assíncrona. Além disso, armazene em cache essa resposta para a próxima vez.
Podemos fazer isso tendo um objeto CACHE
global que armazena o cache exclusivamente. Para exclusividade, podemos usar o valor this.props.page
como uma chave em nosso objeto CACHE
. Então, simplesmente codificamos o algoritmo mencionado acima.
import apiFetch from "./apiFetch"; const CACHE = {}; export default class Component extends React.Component { state = { users: [] }; componentDidMount() { this.load(); } load() { if (CACHE[this.props.page] !== undefined) { this.setState({ users: CACHE[this.props.page] }); } apiFetch(`https://reqres.in/api/users?page=${this.props.page}`).then( json => { CACHE[this.props.page] = json.data; this.setState({ users: json.data }); } ); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { // same render code as above } }
Como o cache é retornado assim que é encontrado e como os novos dados de resposta também são retornados pelo setState
, isso significa que temos atualizações contínuas da interface do usuário e não há mais tempo de espera no aplicativo a partir da segunda solicitação. Isso é perfeito e é o método obsoleto enquanto atualiza em poucas palavras.
A função apiFetch
aqui nada mais é do que um wrapper sobre fetch
para que possamos ver a vantagem do cache em tempo real. Ele faz isso adicionando um usuário aleatório à lista de users
retornada pela solicitação da API. Ele também adiciona um atraso aleatório a ele:
export default async function apiFetch(...args) { await delay(Math.ceil(400 + Math.random() * 300)); const res = await fetch(...args); const json = await res.json(); json.data.push(getFakeUser()); return json; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
A função getFakeUser()
aqui é responsável por criar um objeto de usuário falso.
Com essas mudanças, nossa API é mais real do que antes.
- Tem um atraso aleatório na resposta.
- Ele retorna dados ligeiramente diferentes para as mesmas solicitações.
Diante disso, quando alteramos a prop da page
passada para o Component
do nosso componente principal, podemos ver o cache da API em ação. Tente clicar no botão Alternar uma vez a cada poucos segundos neste CodeSandbox e você deverá ver um comportamento como este:
Se você olhar de perto, algumas coisas acontecem.
- Quando o aplicativo é iniciado e está em seu estado padrão, vemos uma lista de sete usuários. Anote o último usuário da lista, pois é o usuário que será modificado aleatoriamente na próxima vez que essa solicitação for enviada.
- Quando clicamos em Toggle pela primeira vez, ele aguarda um pequeno período de tempo (400-700ms) e depois atualiza a lista para a próxima página.
- Agora, estamos na segunda página. Novamente, anote o último usuário da lista.
- Agora, clicamos em Toggle novamente e o aplicativo voltará para a primeira página. Observe que agora a última entrada ainda é o mesmo usuário que anotamos na Etapa 1 e, posteriormente, muda para o novo usuário (aleatório). Isso ocorre porque, inicialmente, o cache estava sendo mostrado e, em seguida, a resposta real foi ativada.
- Clicamos em Alternar novamente. O mesmo fenômeno acontece. A resposta em cache da última vez é carregada instantaneamente e, em seguida, novos dados são buscados e, assim, vemos a última atualização de entrada do que anotamos na Etapa 3.
É isso, o cache obsoleto durante a atualização que estávamos procurando. Mas essa abordagem sofre de um problema de duplicação de código. Vamos ver como fica se tivermos outro componente de busca de dados com armazenamento em cache. Este componente mostra os itens de forma diferente do nosso primeiro componente.
Adicionando Stale-while-refresh a outro componente
Podemos fazer isso simplesmente copiando a lógica do primeiro componente. Nosso segundo componente mostra uma lista de gatos:
const CACHE = {}; export default class Component2 extends React.Component { state = { cats: [] }; componentDidMount() { this.load(); } load() { if (CACHE[this.props.page] !== undefined) { this.setState({ cats: CACHE[this.props.page] }); } apiFetch(`https://reqres.in/api/cats?page=${this.props.page}`).then( json => { CACHE[this.props.page] = json.data; this.setState({ cats: json.data }); } ); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { const cats = this.state.cats.map(cat => ( <p key={cat.id} style={{ background: cat.color, padding: "4px", width: 240 }} > {cat.name} (born {cat.year}) </p> )); return <div>{cats}</div>; } }
Como você pode ver, a lógica do componente envolvida aqui é praticamente a mesma do primeiro componente. A única diferença está no endpoint solicitado e que mostra os itens da lista de forma diferente.
Agora, mostramos esses dois componentes lado a lado. Você pode ver que eles se comportam de forma semelhante:
Para chegar a esse resultado, tivemos que fazer muita duplicação de código. Se tivéssemos vários componentes como este, estaríamos duplicando muito código.
Para resolvê-lo de maneira não duplicada, podemos ter um Componente de ordem superior para buscar e armazenar dados em cache e transmiti-los como props. Não é o ideal mas vai funcionar. Mas se tivéssemos que fazer várias solicitações em um único componente, ter vários componentes de ordem superior ficaria feio muito rapidamente.
Então, temos o padrão de props de renderização, que provavelmente é a melhor maneira de fazer isso em componentes de classe. Funciona perfeitamente, mas, novamente, é propenso a “embrulhar o inferno” e nos obriga a vincular o contexto atual às vezes. Esta não é uma ótima experiência de desenvolvedor e pode levar a frustração e bugs.
É aqui que os React Hooks salvam o dia. Eles nos permitem encaixotar a lógica do componente em um contêiner reutilizável para que possamos usá-lo em vários lugares. React Hooks foram introduzidos no React 16.8 e funcionam apenas com componentes de função. Antes de chegarmos ao controle de cache do React – em particular, armazenar conteúdo em cache com Hooks – vamos primeiro ver como fazemos a busca simples de dados em componentes de função.

Busca de dados da API em componentes de função
Para buscar dados de API em componentes de função, usamos os ganchos useState
e useEffect
.
useState
é análogo ao state
e setState
dos componentes de classe. Usamos este gancho para ter contêineres atômicos de estado dentro de um componente de função.
useEffect
é um gancho de ciclo de vida e você pode pensar nele como uma combinação de componentDidMount
, componentDidUpdate
e componentWillUnmount
. O segundo parâmetro passado para useEffect
é chamado de matriz de dependência. Quando a matriz de dependências muda, o retorno de chamada passado como o primeiro argumento para useEffect
é executado novamente.
Aqui está como usaremos esses ganchos para implementar a busca de dados:
import React, { useState, useEffect } from "react"; export default function Component({ page }) { const [users, setUsers] = useState([]); useEffect(() => { fetch(`https://reqres.in/api/users?page=${page}`) .then(res => res.json()) .then(json => { setUsers(json.data); }); }, [page]); const usersDOM = users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }
Ao especificar page
como uma dependência para useEffect
, instruímos o React a executar nosso callback useEffect toda vez que a page
for alterada. Isso é como componentDidUpdate
. Além disso, useEffect
sempre é executado na primeira vez, então também funciona como componentDidMount
.
Stale-while-refresh em componentes de função
Sabemos que useEffect
é semelhante aos métodos de ciclo de vida do componente. Assim, podemos modificar a função de retorno de chamada passada para ela para criar o cache obsoleto durante a atualização que tínhamos nos componentes de classe. Tudo permanece o mesmo, exceto o gancho useEffect
.
const CACHE = {}; export default function Component({ page }) { const [users, setUsers] = useState([]); useEffect(() => { if (CACHE[page] !== undefined) { setUsers(CACHE[page]); } apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => { CACHE[page] = json.data; setUsers(json.data); }); }, [page]); // ... create usersDOM from users return <div>{usersDOM}</div>; }
Assim, temos o cache obsoleto durante a atualização trabalhando em um componente de função.
Podemos fazer o mesmo para o segundo componente, ou seja, convertê-lo em função e implementar o cache obsoleto durante a atualização. O resultado será idêntico ao que tivemos nas aulas.
Mas isso não é melhor do que componentes de classe, é? Então, vamos ver como podemos usar o poder de um gancho personalizado para criar lógica modular obsoleta durante a atualização que podemos usar em vários componentes.
Um gancho personalizado obsoleto durante a atualização
Primeiro, vamos restringir a lógica que queremos mover para um gancho personalizado. Se você observar o código anterior, saberá que é a parte useState
e useEffect
. Mais especificamente, esta é a lógica que queremos modularizar.
const [users, setUsers] = useState([]); useEffect(() => { if (CACHE[page] !== undefined) { setUsers(CACHE[page]); } apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => { CACHE[page] = json.data; setUsers(json.data); }); }, [page]);
Como temos que torná-lo genérico, teremos que tornar a URL dinâmica. Então, precisamos ter url
como um argumento. Também precisaremos atualizar a lógica de cache, pois várias solicitações podem ter o mesmo valor de page
. Felizmente, quando a page
é incluída no URL do endpoint, ela gera um valor exclusivo para cada solicitação exclusiva. Assim, podemos usar o URL inteiro como chave para armazenamento em cache:
const [data, setData] = useState([]); useEffect(() => { if (CACHE[url] !== undefined) { setData(CACHE[url]); } apiFetch(url).then(json => { CACHE[url] = json.data; setData(json.data); }); }, [url]);
É quase isso. Depois de envolvê-lo dentro de uma função, teremos nosso gancho personalizado. Dê uma olhada abaixo.
const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); } // fetch new data apiFetch(url).then(newData => { CACHE[cacheID] = newData.data; setData(newData.data); }); }, [url]); return data; }
Observe que adicionamos outro argumento chamado defaultValue
a ele. O valor padrão de uma chamada de API pode ser diferente se você usar esse gancho em vários componentes. É por isso que o tornamos personalizável.
O mesmo pode ser feito para a chave de data
no objeto newData
. Se seu gancho personalizado retornar uma variedade de dados, talvez você queira apenas retornar newData
e não newData.data
e manipular essa travessia no lado do componente.
Agora que temos nosso hook customizado, que faz o trabalho pesado do cache obsoleto durante a atualização, aqui está como o conectamos aos nossos componentes. Observe a grande quantidade de código que conseguimos reduzir. Nosso componente inteiro é agora apenas três instruções. Isso é uma grande vitória.
import useStaleRefresh from "./useStaleRefresh"; export default function Component({ page }) { const users = useStaleRefresh(`https://reqres.in/api/users?page=${page}`, []); const usersDOM = users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }
Podemos fazer o mesmo para o segundo componente. Isso parecerá assim:
export default function Component2({ page }) { const cats = useStaleRefresh(`https://reqres.in/api/cats?page=${page}`, []); // ... create catsDOM from cats return <div>{catsDOM}</div>; }
É fácil ver quanto código clichê podemos economizar se usarmos esse gancho. O código parece melhor também. Se você quiser ver o aplicativo inteiro em ação, acesse este CodeSandbox.
Adicionando um indicador de carregamento para useStaleRefresh
Agora que temos o básico no ponto, podemos adicionar mais recursos ao nosso gancho personalizado. Por exemplo, podemos adicionar um valor isLoading
no gancho que é verdadeiro sempre que uma solicitação exclusiva é enviada e não temos nenhum cache para mostrar enquanto isso.
Fazemos isso tendo um estado separado para isLoading
e configurando-o de acordo com o estado do gancho. Ou seja, quando nenhum conteúdo da Web em cache está disponível, nós o configuramos como true
, caso contrário, definimos como false
.
Aqui está o gancho atualizado:
export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); } // fetch new data apiFetch(url).then(newData => { CACHE[cacheID] = newData.data; setData(newData.data); setLoading(false); }); }, [url]); return [data, isLoading]; }
Agora podemos usar o novo valor isLoading
em nossos componentes.
export default function Component({ page }) { const [users, isLoading] = useStaleRefresh( `https://reqres.in/api/users?page=${page}`, [] ); if (isLoading) { return <div>Loading</div>; } // ... create usersDOM from users return <div>{usersDOM}</div>; }
Observe que, feito isso, você vê o texto “Carregando” quando uma solicitação exclusiva é enviada pela primeira vez e nenhum cache está presente.
Fazendo useStaleRefresh
Support Qualquer função async
Podemos tornar nosso gancho personalizado ainda mais poderoso, tornando-o compatível com qualquer função async
em vez de apenas solicitações de rede GET
. A ideia básica por trás dele permanecerá a mesma.
- No gancho, você chama uma função assíncrona que retorna um valor após algum tempo.
- Cada chamada exclusiva para uma função assíncrona é armazenada em cache corretamente.
Uma simples concatenação de function.name
e arguments
funcionará como uma chave de cache para nosso caso de uso. Usando isso, é assim que nosso gancho ficará:
import { useState, useEffect, useRef } from "react"; import isEqual from "lodash/isEqual"; const CACHE = {}; export default function useStaleRefresh(fn, args, defaultValue = []) { const prevArgs = useRef(null); const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // args is an object so deep compare to rule out false changes if (isEqual(args, prevArgs.current)) { return; } // cacheID is how a cache is identified against a unique request const cacheID = hashArgs(fn.name, ...args); // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); } // fetch new data fn(...args).then(newData => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [args, fn]); useEffect(() => { prevArgs.current = args; }); return [data, isLoading]; } function hashArgs(...args) { return args.reduce((acc, arg) => stringify(arg) + ":" + acc, ""); } function stringify(val) { return typeof val === "object" ? JSON.stringify(val) : String(val); }
Como você pode ver, estamos usando uma combinação de nome de função e seus argumentos de string para identificar exclusivamente uma chamada de função e, assim, armazená-la em cache. Isso funciona para nosso aplicativo simples, mas esse algoritmo é propenso a colisões e comparações lentas. (Com argumentos não serializáveis, não funcionará.) Portanto, para aplicativos do mundo real, um algoritmo de hash adequado é mais apropriado.
Outra coisa a notar aqui é o uso de useRef
. useRef
é usado para persistir os dados durante todo o ciclo de vida do componente envolvente. Como args
é um array—que é um objeto em JavaScript—cada re-renderização do componente usando o gancho faz com que o ponteiro de referência args
mude. Mas args
faz parte da lista de dependências em nosso primeiro useEffect
. Portanto, a mudança de args
pode fazer com que nosso useEffect
executado mesmo quando nada mudou. Para contrariar isso, fazemos uma comparação profunda entre argumentos antigos e atuais usando args
e apenas deixamos o callback useEffect
ser executado se os args
realmente forem alterados.
Agora, podemos usar esse novo gancho useStaleRefresh
da seguinte maneira. Observe a mudança em defaultValue
aqui. Como é um gancho de uso geral, não estamos contando com nosso gancho para retornar a chave de data
no objeto de resposta.
export default function Component({ page }) { const [users, isLoading] = useStaleRefresh( apiFetch, [`https://reqres.in/api/users?page=${page}`], { data: [] } ); if (isLoading) { return <div>Loading</div>; } const usersDOM = users.data.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }
Você pode encontrar o código inteiro neste CodeSandbox.
Não deixe os usuários esperando: use o conteúdo do cache de forma eficaz com ganchos obsoletos durante a atualização e React
O hook useStaleRefresh
que criamos neste artigo é uma prova de conceito que mostra o que é possível com React Hooks. Tente brincar com o código e veja se você pode encaixá-lo em seu aplicativo.
Alternativamente, você também pode tentar aproveitar a atualização durante a atualização por meio de uma biblioteca de código aberto popular e bem mantida, como swr ou react-query. Ambas são bibliotecas poderosas e suportam uma série de recursos que ajudam nas solicitações de API.
React Hooks são um divisor de águas. Eles nos permitem compartilhar a lógica dos componentes de forma elegante. Isso não era possível antes porque o estado do componente, os métodos do ciclo de vida e a renderização eram todos empacotados em uma entidade: componentes de classe. Agora, podemos ter módulos diferentes para todos eles. Isso é ótimo para composição e escrever código melhor. Estou usando componentes de função e ganchos para todo o novo código React que escrevo, e recomendo isso a todos os desenvolvedores React.