Criando um custom hook para trabalhar com localStorage
- Lucas Akira
- Sem categoria
- mar 13, 2022
Uma coisa muito comum que nós fazemos como desenvolvedores de Front-end é utilizar o LocalStorage para poder persistir algumas informações por exemplo: um token de autenticação, se o tema é claro ou escuro, ou outras configurações relativas as preferencias do usuário.
E quando vamos trabalhar com React, muitas vezes precisamos utilizar o LocalStorage em conjunto com as nossas states, o que envolve uma certa quantidade de código para manter tudo sincronizado, por exemplo:
// @filepath: src/Example.tsx
import { useEffect, useState } from "react";
const Example = () => {
const initialName = localStorage.getItem("name") || "";
const [name, setName] = useState(localStorage.getItem("name") || "");
useEffect(() => localStorage.setItem("name", name), [name]);
return (
<main>
<label htmlFor="name">
Name
<input
id="name"
type="text"
value={name}
onChange={({ target }) => setName(target.value)}
/>
</label>
</main>
);
};
export default Example;
O princípio é bem simples, nós criamos uma state com o useState, e definimos que o seu valor inicial vai ser a chave associada com ela no localStorage, e ai usamos o ||
para o caso de ser a primeira rendenização, e a state ainda não existir no localStorage.
Para salvar as alterações nessa state no localStorage, nós utilizamos um useEffect para ficar observando as mudanças nela, e toda vez que ele for executado, nós chamamos o setItem com a chave que nós definimos no getItem do useState, e salvamos a state no segundo parâmetro.
Essa abordagem tem alguns problemas, por exemplo, o fato de termos o nome da chave (no caso do nosso exemplo name) duplicada tanto no getItem quanto no setItem, e isso pode causar algum bug se tivermos que mudar ela para outra coisa, e errarmos em um dos dois por algum descuído de digitação, e o segundo problema é que é um código até que grande para lidar com uma state só em um componente só, agora imagine se nós tivessemos que fazer isso em outros componentes também, daria muito trabalho que na maior parte do tempo seria um grande copiar e colar, além das chances de erros humanos como citado anteriormente.
Por isso, não seria interessante que nós pudessemos abstrair esse uso do localStorage em algum lugar para evitar essa repetição toda? E é isso que nós vamos fazer hoje: um hook customizado que nós podemos utilizar como se fosse o useState
, mas que sincroniza a state com o localStorage.
Começando nosso hook
Primeiro de tudo vamos criar um arquivo separado para o nosso hook, nós podemos chama-lo de use-local-storage.ts
:
// @filepath: src/hooks/use-local-storage.ts
import { useEffect, useState } from "react";
const useLocalStorage = (key: string, defaultValue: string) => {
const initialState = localStorage.getItem(key) || defaultValue;
const [state, setState] = useState(initialState);
useEffect(() => localStorage.setItem(key, state), [state]);
return [state, setState] as const;
};
export default useLocalStorage;
Nós fazemos ele receber dois parâmetros, key (que é onde nós vamos salvar), e o defaultValue que a princípio é uma string, e ai nós isolamos só a parte da lógica de manipulação do localStorage.
Perceba que essêncialmente nós copiamos e colamos a implementação do componente Example e substituímos as partes dinâmicas por variáveis ao invés de valores hard-coded.
E depois nós retornamos uma tupla (array) com o primeiro item sendo a state
, e o segundo o setState
, assim nós temos a mesma saída que teríamos se usassemos o useState
.
Um detalhe legal de notar antes de nós prosseguirmos, é que no retorno nós temos um trecho escrito as const
, esse é um trecho que diz ao TypeScript que o array que nós estamos retornando vai ter exatamente aquela estrutura, com o primeiro item sendo a state (que por enquanto é do tipo string) e o segundo sendo o setState, além de informar ao TS que eles readonly, o que significa que não podemos usar o operador de atribuição neles (=).
Precisamos desse trecho pois senão o compilador do TS não vai saber qual o tipo de cada item quando nós formos desestruturar esse array na hora de usar o nosso hook, pois como esse array tem dois tipos diferentes dentro dele, o tipo que é inferido pelo TS vai ser algo como: (string | React.Dispatch<React.SetStateAction<string>>)[]
, e não é isso que nós queremos, o tipo que nós queremos é que ele seja um tipo de tupla mesmo, algo como: [string, React.Dispatch<React.SetStateAction<string>>]
, que é o tipo gerado depois da aplicação do as const
no array retornado.
Legal, agora que nós já entendemos como tudo foi implementado, vamos ver como fica a nova implementação do nosso componente Example:
// @filepath: src/Example.tsx
import useLocalStorage from "./hooks/use-local-storage";
const Example = () => {
const [name, setName] = useLocalStorage("name", "");
return (
<main>
<label htmlFor="name">
Name
<input
id="name"
type="text"
value={name}
onChange={({ target }) => setName(target.value)}
/>
</label>
</main>
);
};
export default Example;
Bem mais limpa, não é mesmo? Mas, e se quisessemos adicionar um novo campo para idade, e então guardar um número no nosso hook? Ou até mesmo se fossemos usar para algo mais complexo e quisessemos guardar um array ou objeto literal (sem os métodos, isso não é possível)?
Atualmente, o nosso hook só sabe lidar com strings, afinal o localStorage só consegue armazenar strings, mas como poderiamos fazer ele funcionar com outros tipos?
O primeiro passo é nós ajustarmos a tipagem dele para agradar ao compilador do TS, e para tornar essa tipagem um pouco mais dinâmica, vamos utilizar generics e ao invés de deixar a state e o defaultValue como string, vamos tipar eles com um generic:
const useLocalStorage = <T>(key: string, defaultValue: T) => {
const initialState = localStorage.getItem(key) || defaultValue;
const [state, setState] = useState(initialState);
useEffect(() => localStorage.setItem(key, state), [state]);
return [state, setState] as const;
};
Porém, como dito anteriormente o setItem
só aceita string, então o TS já está nos avisando que passar um item do tipo T (que é o nosso generic) não vai funcionar, então como podemos fazer para converter qualquer valor para uma string?
Um dos jeitos possíveis é serializar esses valores no formato de um JSON (afinal é meio que para isso que usamos JSON para trafegar dados na internet, já que ele é uma forma padronizada de codificar os dados em um texto e depois decodificar em algo que a linguagem de programação entenda) na hora de inserir, e depois desserializar eles na hora de recuperar. Então bora aplicar isso no nosso exemplo:
// @filepath: src/hooks/use-local-storage.ts
import { useEffect, useState } from "react";
const useLocalStorage = <T>(key: string, defaultValue: T) => {
const storedItem = localStorage.getItem(key);
const initialState: T = storedItem ? JSON.parse(storedItem) : defaultValue;
const [state, setState] = useState(initialState);
useEffect(() => {
// JSON.stringify para codificar em JSON
localStorage.setItem(key, JSON.stringify(state));
}, [state]);
return [state, setState] as const;
};
export default useLocalStorage;
Aqui nós primeiro verificamos se o item existe no localStorage antes de decodificar usando o JSON.parse, pois se ele não existir, nós simplesmente usamos o defaultValue direto. E agora nosso hook deve conseguir lidar com outros tipos além de string, por isso vamos dar uma atualizada no nosso componente Example:
// @filepath: src/Example.tsx
import useLocalStorage from "./hooks/use-local-storage";
const Example = () => {
const [name, setName] = useLocalStorage("name", "");
const [age, setAge] = useLocalStorage("age", 0);
return (
<main>
<label htmlFor="name">
Name
<input
id="name"
type="text"
value={name}
onChange={({ target }) => setName(target.value)}
/>
</label>
<label htmlFor="age">
Age
<input
id="age"
type="text"
value={age}
onChange={({ target }) => setAge(+target.value)}
/>
</label>
</main>
);
};
export default Example;
Otimizando a performance do nosso hook
E tudo está funcionando como o esperado, só que tem mais uma coisa que podemos fazer para melhorar o nosso hook, que é melhorar um pouco a performance dele, vamos voltar a nossa implementação dele e nos atentar a um detalhe:
const storedItem = localStorage.getItem(key);
const initialState: T = storedItem ? JSON.parse(storedItem) : defaultValue;
const [state, setState] = useState(initialState);
Nesse trecho, o initialState é calculado com base no valor retornado do localStorage, e tem funcionado bem até agora, porém ler/escrever algo no localStorage é uma operação que envolve I/O (entrada e saída), ou seja, é uma operação bem mais custosa em termos de performace e processamento, e o initialState é algo computado apenas na primeira rendenização do nosso componente, o que significa que todas rendenizações que ele fizer em seguida não utilizarão o valor do initialState, porém como um componente React é uma função, ela continuará sendo executada novamente durante essas rendenizações, e isso fará com que essa operação custosa para o navegador que é acessar o localStorage seja executada todas essas vezes sem necessidade.
Então como podemos fazer para que essa leitura do localStorage seja executada só na primeira rendenização de fato? Podemos utilizar uma feature do useState
chamada de lazy initial state, que consiste em passarmos uma função que retorna o estado inicial como parâmetro para o useState ao invés de seu estado inicial diretamente. Dessa forma podemos mover o trecho que nós revisitamos a pouco para uma função e passar ela como parâmetro do useState
:
// @filepath: src/hooks/use-local-storage.ts
import { useEffect, useState } from "react";
const useLocalStorage = <T>(key: string, defaultValue: T) => {
const [state, setState] = useState<T>(() => {
const storedItem = localStorage.getItem(key);
return storedItem ? JSON.parse(storedItem) : defaultValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [state]);
return [state, setState] as const;
};
export default useLocalStorage;
Espero que este artigo tenha sido útil para você que chegou até aqui, considere se juntar a nossa comunidade se inscrevendo no nosso curso, e até a próxima.