Вступление

В современном веб‑деве скорость и производительность приложений имеют решающее значение. Пользователи ожидают, что страницы будут загружаться мгновенно, а разработчики стремятся к созданию максимально отзывчивых интерфейсов. Одним из эффективных методов повышения производительности является кэширование данных.

Кэширование позволяет значительно сократить время загрузки и уменьшить нагрузку на сервер, сохраняя часто используемые данные в local storage. В данной статье мы рассмотрим один из способов кэширования в React‑приложениях — используя хуки.

Внедрим технологию кэширования в существующее React приложение

Суть приложения: на странице сделаем форму, в которой пользователь впишет id покемона и будут возможны 3 варианта:

  1. Покемон будет обнаружен в кэше и его компонент подгрузится моментально, используя данные из local storage

  2. Покемон не будет найден в хранилище. Тогда мы отправим запрос на сервер и дадим пользователю возможность сохранить данные в кэш

  3. id покемона не будет найден ни в хранилище, ни на сервере — отправим ошибку

Также, данные, которые будут хранится в кэше больше часа (или любого другого времени) будут удалены, чтобы не захламлять хранилище.

Для апи буду использовать pokeapi.co — моковый апи, который как раз подойдет для наших задач.

Также, воспользуюсь библиотекой reactuse. Это самый большой набор переиспользуемых react хуков. Используя эту библиотеку вы сможете перестать париться о дефолтном веб функционале, ведь эту работу за вас возьмут хуки из пакета. Из reactuse возьмем 3 хука: useLocalStorage, useQuery, useMount.

Ставим библиотеку:

$ npm i @siberiacancode/reactuse --save
# или
$ yarn add @siberiacancode/reactuse

Создадим развертку для формы с input элементом (для ввода id покемона)

import { useState } from "react";

function App() {
  const [inputText, setInputText] = useState("");
  const [pokemonId, setPokemonId] = useState("");
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setInputText(event.target.value);
  };
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setPokemonId(inputText);
  };  return (
    <div>
      <form onSubmit={handleSubmit}>
        <label>Enter id of the pokemon </label>
        <input onChange={handleChange} type="text" />
        <button type="submit">Find Pokemon</button>
      </form>
    </div>
  );
}

Здесь мы используем useState, чтобы хранить значение из поля ввода в состоянии inputText. На onChange обновляем значение этого стэйта до актуального. Также создадим стэйт pokemonId, если в случае с inputText мы просто храним любую информацию, которую пользователь вводит в инпут, то здесь мы уже храним конкректный айди покемона, по которому будем отправлять запрос.

Создаем функцию handleSubmit. Она будет вызываться, когда форма будет отправлена. В ней мы вписываем, что как только форма будет подтверждена - мы запишем в pokemonId значение из нашего поля ввода. Мы используем e.preventDefault() чтобы страница не перезагружалась, как только форма будет отправлена.

Теперь создадним новый компонент Pokemon и в пропсах будем принимать id покемона

interface PokemonProps {
id: string
}
const Pokemon = ({id}:PokemonProps) => {
return <div>Pokemon!</div>;
};
export default Pokemon;

Вернемся в главный компонент и вызывем Pokemon (только если стэйст pokemonId не пустой) и передадим в пропсы стэйт pokemonId.

</form>
      {pokemonId && <Pokemon id={pokemonId} />}
      ...

Теперь мы будем работать только с компонентом Pokemon. Здесь мы должны реализовать следующую логику:

  1. Найти id покемона в кэше

    1. в случае, если он найден — отрисовать данные из кэша

    2. в случае, если его там нет — отправить запрос на сервер, получить данные, и отрисовать кнопку записи в кэш

  2. Если прошло больше определенного времени — удалить определенного покемона из кэша

Импортируем хуки:

import { useLocalStorage, useMount, useQuery} from "@siberiacancode/reactuse";

Создадим интерфейс покемона

interface IPokemon {
  id: string;
  name: string;
  imageURL: string;
  expiresAt: number;
}

Создидим константу, показывающую, через сколько секунд данные из кэша должны удалиться

const CACHE_EXPIRATION_TIME = 3600; // seconds

Используем хук useLocalStorage по ключу «pokemons». И сразу создадим константу cachedPokemon в ней мы будем хранить данные покемона из кэша, по запрошенному айди (если он конечно там имеется)

const { value, set } = useLocalStorage<IPokemon[]>("pokemons");
const cachedPokemon = value?.find((p) => p.id === id);

Создаем запрос к апи, используя useQuery:

const { data, isLoading, isError, error } = useQuery(
    () =>
      fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then((res) =>
        res.json()
      ),
    { keys: [id], enabled: !cachedPokemon }
  );

Мы делаем запрос по айди, который берем в пропсах. Важно: в options хука указываем в keys: айди, чтобы запрос шел заново, как только обновится проп id. Также добавляем enabled: !cachedPokemon, это можно перевести так: «отправлять запрос только в том случае, если не получилось достать покемона из кэша»

Теперь делаем ряд проверок и отрисовываем ui

if (cachedPokemon)
    return (
      <div>
        <h1>{cachedPokemon.name}</h1>
        <h3>{cachedPokemon.id}</h3>
        <p style={{ color: "green" }}>Loaded from cache</p>
        <img src={cachedPokemon.imageURL} alt={cachedPokemon.name} />
      </div>
    );

Если получилось достать покемона из кэша, то отрисуем его (зеленым текстом допишем, что данные были взяты из хранилища, и подгружаются оптимизировано).

Проверки на ошибку и loading данных:

if (isLoading) return <h3>Fetching Pokemon...</h3>;
if (isError) return <h3>Error: {error?.message}</h3>;

И в случае если не получилось достать покемона и нам пришлось делать запрос на сервер, то отрисуем компонент покемона, добавим кнопку «Save in cache», и красным текстом отметим, что данные были взяты с сервера.

// В expiresAt прописываем текущее время (Date.now() ) + константу CACHE_EXPIRATION_TIME , помноженную на 1000 (чтобы означала секунды)

if (data && !cachedPokemon) {
    const fetchedPokemon: IPokemon = {
      id: String(data.id),
      name: data.name,
      imageURL: `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${data.id}.png`,
      expiresAt: Date.now() + CACHE_EXPIRATION_TIME * 1000,
    };
    return (
      <div>
        <h1>{fetchedPokemon.name}</h1>
        <h3>{fetchedPokemon.id}</h3>
        <div>
          <p style={{ color: "red" }}>Loaded from server.</p>
          <button
            onClick={() => {
              let arrayOfPokemons = value;
              arrayOfPokemons?.push(fetchedPokemon);
              arrayOfPokemons ? set(arrayOfPokemons) : set([fetchedPokemon]);
            }}
          >
            Save in cache
          </button>
        </div>
    &lt;img src={fetchedPokemon.imageURL} alt={data.name} /&gt;
  &lt;/div&gt;
);

}

При нажатии на кнопку создадим отдельную локальную переменную ArrayOfPokemons, равную текущему значению состояния из кэша, добавим в массив запрошенного с сервера покемона (fetchedPokemon) и поместим в сторэдж этот массив, включающий и прошлое состояние из кэша, и нового покемона.

Теперь вернемся в самое начало кода, и перед объявлением useLocalStorage пропишем следующее:

useMount(() => {
    let arrayOfPokemons = value;
    arrayOfPokemons?.map((pokemon, index) => {
      if (pokemon.expiresAt <= Date.now()) {
        arrayOfPokemons?.splice(index);
      }
    });
    arrayOfPokemons && set(arrayOfPokemons);
  });

На маунт компонента, мы будем проходиться по массиву покемонов из кэша, и если какой то из покемонов уже expired (Date.now() ≥ expiresAt), то удалим покемона из массива.

На этом все!

Демо работы приложения

Весь код

//App.tsx
import { useState } from "react";
import Pokemon from "./Pokemon";

function App() {
  const [inputText, setInputText] = useState("");
  const [pokemonId, setPokemonId] = useState("");
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setInputText(event.target.value);
  };
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setPokemonId(inputText);
  };
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <label>Enter id of the pokemon </label>
        <input onChange={handleChange} type="text" />
        <button type="submit">Find Pokemon</button>
      </form>

      {pokemonId && <Pokemon id={pokemonId} />}
    </div>
  );
}

export default App;
//Pokemon.tsx
import { useLocalStorage, useMount, useQuery } from "@siberiacancode/reactuse";

interface PokemonProps {
  id: string;
}

interface IPokemon {
  id: string;
  name: string;
  imageURL: string;
  expiresAt: number;
}

const CACHE_EXPIRATION_TIME = 3600; // seconds

const Pokemon = ({ id }: PokemonProps) => {
  const { value, set } = useLocalStorage<IPokemon[]>("pokemons");

  useMount(() => {
    let arrayOfPokemons = value;
    arrayOfPokemons?.map((pokemon, index) => {
      if (pokemon.expiresAt <= Date.now()) {
        arrayOfPokemons?.splice(index);
      }
    });
    arrayOfPokemons && set(arrayOfPokemons);
  });

  const cachedPokemon = value?.find((p) => p.id === id);

  const { data, isLoading, isError, error } = useQuery(
    () =>
      fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then((res) =>
        res.json()
      ),
    { keys: [id], enabled: !cachedPokemon }
  );

  if (cachedPokemon)
    return (
      <div>
        <h1>{cachedPokemon.name}</h1>
        <h3>{cachedPokemon.id}</h3>
        <p style={{ color: "green" }}>Loaded from cache</p>
        <img src={cachedPokemon.imageURL} alt={cachedPokemon.name} />
      </div>
    );

  if (isLoading) return <h3>Fetching Pokemon...</h3>;
  if (isError) return <h3>Error: {error?.message}</h3>;

  if (data && !cachedPokemon) {
    const fetchedPokemon: IPokemon = {
      id: String(data.id),
      name: data.name,
      imageURL: `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${data.id}.png`,
      expiresAt: Date.now() + CACHE_EXPIRATION_TIME * 1000,
    };
    return (
      <div>
        <h1>{fetchedPokemon.name}</h1>
        <h3>{fetchedPokemon.id}</h3>
        <div>
          <p style={{ color: "red" }}>Loaded from server.</p>
          <button
            onClick={() => {
              let arrayOfPokemons = value;
              arrayOfPokemons?.push(fetchedPokemon);
              arrayOfPokemons ? set(arrayOfPokemons) : set([fetchedPokemon]);
            }}
          >
            Save in cache
          </button>
        </div>

        <img src={fetchedPokemon.imageURL} alt={data.name} />
      </div>
    );
  }
  return <div>Sorry...</div>;
};

export default Pokemon;

reactuse ссылки

npm — github

Комментарии (2)


  1. Batyodie
    17.07.2024 05:52

    Чем это решение лучше tansck query(react-query)? В tanstack query также есть поддержка localStorage.


  1. savostin
    17.07.2024 05:52

    “Отличная» документация у reactuse

    Увидел ссылку в статье