26/4/2024

Погружаемся в магию React Query

  • Никита Донцов

    Никита Донцов

    Фронтенд-разработчик партнерских продуктов Яндекс Такси

Можно не верить в чудеса, но в мире разработки некоторые вещи выглядят как реальная магия. Например, код, который запускается и работает с первого раза и без ошибок, или код, который на 100% покрыт тестами. Смотришь и думаешь: «Откуда вообще у людей столько времени, чтобы всё это писать? Магия, не иначе!»

Шутки шутками, но в реальном мире разработки есть библиотека, которая кажется чем-то по-настоящему волшебным. Это библиотека React-Query. Всего пара строк кода, и у нас есть готовое решение, которое поддерживает индикацию загрузки, отображение ошибок, кэширование и всё, что только можно придумать.

Привет!
Меня зовут Никита. Я фронтенд-разработчик партнерских продуктов Яндекс Такси, и я очень люблю React-Query. Как у многих разработчиков интерфейсов, у меня есть травмирующий опыт работы с Redux.

Проблема в том, что достаточно простую библиотеку используют, чтобы и гвозди забивать, и космические корабли строить. Зачастую при таком подходе, когда смешиваются данные с сервера, бизнес-логика и маппинг данных, получается огромный монстр, которого потом тяжело поддерживать.

У меня был такой опыт на предыдущем проекте. В какой-то момент приложение стало очень большим, в нём оказалась смесь из самых разных сущностей. Решением для нас оказался переезд на библиотеку React-Query, благодаря чему мы отделили бизнес-логику от простого запроса данных и их маппинга в интерфейс. Объем кода значительно сократился, нам стало проще с ним работать.

В статье покажу, как использовать React-Query в своих проектах, чтобы вы тоже почувствовали радость и немного магии. План такой:

  1. Разбираемся с демо-приложением.
  2. Получаем данные в useQuery решением «в лоб».
  3. Создаем query как отдельную сущность.
  4. Сохраняем queries в кэш в QueryClient.
  5. Получаем данные в useQuery с использованием query.
  6. Добавляем дедупликацию запросов.
  7. Отслеживаем изменения через подписку на query.

Эту статью подготовила редакция Dev Go Яндекс на основе выступления Никиты на Яндекс Go Frontend Meetup #2. Чтобы погрузиться в тему подробнее, следите за кодом на GitHub и смотрите запись выступления!

Разбираемся с демо-приложением

У нас есть небольшое React-приложение, которое запрашивает список постов в реальном времени с помощью сервиса JSONPlaceholder. По клику на пост мы отображаем его содержимое.

В коде, который делает эту магию, всё достаточно просто.

import React from "react"

import { fetchWithTimeout } from "../lib/fetch-with-timeout"
import { BackLink, PostComponent, PostLink } from "./ui"

import { QueryClientProvider, useQuery, QueryClient } from "@tanstack/react-query"
// import { useQuery, QueryClient, QueryClientProvider } from "../lib/stub"

const queryClient = new QueryClient()

export function App() {
  const [postId, setPostId] = React.useState()

  return (
    <QueryClientProvider client={queryClient}>
      {postId ? (
        <Post postId={postId} setPostId={setPostId} />
      ) : (
        <Posts setPostId={setPostId} />
      )}
    </QueryClientProvider>
  )
}

Мы импортируем QueryClientProvider, useQuery и QueryClient и создаем instance QueryClient. Оборачиваем наше приложение в QueryClientProvider. Далее в зависимости от текущего ID рендерим либо компонент списка постов, либо конкретный пост.

function usePosts() {
  return useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const response = await fetchWithTimeout(
        "https://jsonplaceholder.typicode.com/posts"
      )
      return await response.json()
    },
    staleTime: 3000,
  })
}

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

function Posts({ setPostId }) {
  const { status, data } = usePosts()

  return (
    <main>
      <h1>Posts</h1>
      <div>
        {status === "loading" && <p>Loading...</p>}
        {status === "success" && (
          <>
            {data?.map((post) => (
              <PostLink key={post.id} onClick={setPostId} {...post} />
            ))}
          </>
        )}
      </div>
    </main>
  )
}

Перейдем к разметке, тут два сценария:

  • Пока у нас запрос не завершился, показываем статус загрузки.
  • Когда запрос успешно отработал, мы отображаем список постов.
function usePost(postId) {
  return useQuery({
    queryKey: ["post", postId],
    queryFn: async ({ queryKey }) => {
      const [, postId] = queryKey
      const response = await fetchWithTimeout(
        `https://jsonplaceholder.typicode.com/posts/${postId}`
      )
      return await response.json()
    },
  })
}

function Post({ postId, setPostId }) {
  const { status, data } = usePost(postId)

  return (
    <main>
      <BackLink onClick={setPostId} />
      {status === "loading" && <p>Loading...</p>}
      {status === "success" && <PostComponent {...data} />}
    </main>
  )
}

В компоненте поста всё почти так же: есть загрузка и успешно запрошенные данные, но существует одно небольшое отличие. Чтобы запросить пост, мы передаем вторым аргументом идентификатор поста, а затем в функции queryFn добавляем его в запрос. С помощью такого пробрасывания данных React-Query самостоятельно отслеживает обновление параметров запроса.

Когда вы пишете на Redux или другом инструменте, зачастую приходится создавать большие и сложные useEffects, в которых нужно перечислять все зависимости и вручную делать запрос, как только одна из этих зависимостей обновляется.

Теперь этого делать не нужно: библиотека React-Query сама следит за обновлением зависимостей и, если какая-то из них обновляется, автоматически выполняет запрос данных.

import { useEffect, useState, useRef, useReducer, createContext, useContext } from "react"

const context = createContext(null)

export function useQueryClient() {
  const queryClient = useContext(context)

  if (!queryClient) {
    throw new Error("No QueryClient")
  }

  return queryClient
}

export function QueryClientProvider({ client, children }) {
  return <context.Provider value={client}>{children}</context.Provider>
}

export class QueryClient {
  constructor() {}
}

export const useQuery = ({ queryKey, queryFn, staleTime }) => {
  return {
    status: "loading",
    data: null,
  }
}

Теперь разберемся с кодом заглушки. Здесь есть простой контекст, в котором мы будем складывать instance QueryClient, есть небольшой хук-обертка для того, чтобы получать QueryClient из контекста, и провайдер, чтобы передавать наш QueryClient внутрь дерева React-компонентов. Также у нас есть заготовки для класса QueryClient и заготовка под хук useQuery.

Получаем данные в useQuery решением «в лоб»

Чтобы запросить данные, нам нужно в момент монтирования компонента сделать запрос, в процессе показать статус загрузки и отрендерить полученные данные.

export const useQuery = ({ queryKey, queryFn, staleTime }) => {
  const [status, setstatus] = useState("loading")
  const [data, setdata] = useState()

  useEffect(() => {
    queryFn({ queryKey }).then((result) => {
      setstatus("success")
      setdata(result)
    })
  }, [])

  return {
    status,
    data,
  }
}

Чтобы этого добиться, проинициализируем статус состояния как loading, дату оставим undefined. Вернем их из хука и будем делать запрос при монтировании компоненты. Как аргумент запроса передаем ключ и, когда запрос успешно отработает, переключаем состояние с помощью вызова функций setstatus и setdata.

Мы загрузили и список постов, и данные для конкретного поста. На этом моменте стоит остановиться и отметить два важных момента:

  1. Если посмотреть в консоль разработчика, можно обнаружить, что в процессе получения списка постов происходит не один, а два запроса.
  2. Мы не можем перезапустить запрос или повторно использовать эти данные в другом компоненте, не делая лишний запрос.

Далее будем решать эти проблемы.

Создаем query как отдельную сущность

Для этого введем некоторую абстракцию над запросом. Сейчас у нас есть только простой вызов функции, которая выполняет запрос.

Создадим некоторую абстракцию и назовем её query. Она включает в себя некоторое состояние, функцию для его обновления и функцию, которая делает запрос и изменяет состояние. Напишем функцию для создания query.

const createQuery = ({ queryKey, queryFn, staleTime }) => {
  const query = {
    queryKey,
    state: {
      status: "loading",
      data: undefined
    },
    setState: (updaterFn) => {
      query.state = updaterFn(query.state)
    }
  }

  return query
}

Передадим опции из хука — в результате наша query будет представлять собой простой объект, который возвращается из функции. У query есть тот ключ, который мы ей передали — уникальное имя и опциональные параметры запроса, состояние и функция для его обновления. Она принимает текущее состояние и возвращает его изменённую версию.

Следующим шагом добавим функцию для того, чтобы делать запрос.

const createQuery = ({ queryKey, queryFn, staleTime }) => {
  const query = {
    queryKey,
    state: {
      status: "loading",
      data: undefined
    },
    setState: (updaterFn) => {
      query.state = updaterFn(query.state)
    },
    fetch: async () => {
      return query.state.data
    }
  }

  return query
}

Когда мы отправляем запрос, нам важно:

  • знать, что запрос начался;
  • отображать loader или spinner;
  • понимать, завершился ли запрос успешно или нет;
  • получить данные, сообщение об ошибке или другую информацию;
  • убедиться, что запрос полностью завершился.
const createQuery = ({ queryKey, queryFn, staleTime }) => {
  const query = {
    queryKey,
    state: {
      status: "loading",
      data: undefined
    },
    setState: (updaterFn) => {
      query.state = updaterFn(query.state)
    },
    fetch: async () => {
      query.setState((oldState) => ({ ...oldState, status: "loading" }))

      try {
        const data = await queryFn({ queryKey })
        query.setState(( oldState ) => ({ ...oldState, status: "success", data }))
      } catch (error) {}

      return query.state.data
    }
  }

  return query
}

Получается ровно три состояния:

  • Когда мы говорим о том, что у нас начался запрос, меняем состояние на loading. Затем в блоке try-catch будем делать запрос данных.
  • Если данные были успешно запрошены, мы меняем наше состояние на success и копируем данные, которые вернул запрос, в состояние query.

У нас получилась функция, которая создает некую абстракцию над запросом. Она называется query. У этой абстракции есть ключ, состояние, функция для обновления состояния и fetch-функция, которая делает запрос.

Это необходимо, потому что в данном коде мы управляем состоянием в React. Решение простое и легкое, но оно недостаточно гибкое для наших задач, поэтому мы переносим всё управление состоянием в абстракцию query. В дальнейшем React будет просто получать уведомление о том, что что-то изменилось и это что-то нужно перерисовать.

Сохраняем queries в кэш в QueryClient

Мы создали query. Это некая абстракция над запросом, поэтому возникает вопрос, как же её хранить. Нам подойдет глобальный кэш QueryClient.

export function QueryClientProvider({ client, children }) {
  return <context.Provider value={client}>{children}</context.Provider>
}

export class QueryClient {
  constructor() {
    this.queries = []
  }

  getQuery = ({ queryKey, queryFn, staleTime }) => {
    
  }
}

Мы будем сохранять запросы в массив, который назовем queries. В конструкторе проинициализируем его и создадим метод getQuery, который будет получать те же опции. Задача метода — вернуть из кэша уже запрошенные данные, чтобы не делать лишние запросы и не заставлять пользователя ждать. Если данные не найдены в кэше, нужно создать новый query, который сходит в интернет и вернёт для пользователя новые данные.

Возникает вопрос: как нам различать одну query от другой? У нас есть параметр queryKey. Это массив, внутри которого хранится уникальное имя и параметр запроса, то есть массив со вложенным объектом. Для того, что сравнивать было удобнее, мы сериализуем объекты и сравниваем их строковое представление.

const createQuery = ({ queryKey, queryFn, staleTime }) => {
  const query = {
    queryKey,
    queryHash: JSON.stringify(queryKey),
    state: {
      status: "loading",
      data: undefined
    },
    setState: (updaterFn) => {
      query.state = updaterFn(query.state)
    },
    fetch: async () => {
      query.setState((oldState) => ({ ...oldState, status: "loading" }))

      try {
        const data = await queryFn({ queryKey })
        query.setState(( oldState ) => ({ ...oldState, status: "success", data }))
      } catch (error) {}

      return query.state.data
    }
  }

  return query
}
export class QueryClient {
  constructor() {
    this.queries = []
  }

  getQuery = ({ queryKey, queryFn, staleTime }) => {
    const queryHash = JSON.stringify(queryKey)
    let query = this.queries.find((v) => v.queryHash === queryHash)

    if (!query) {
        query = createQuery({ queryKey, queryFn, staleTime })
        this.queries.push(query)
    }

    return query
  }
}

В методе getQuery мы тоже будем сериализовывать пришедший к нам ключ и искать в кэше, есть ли у нас уже query для этого ключа. Если есть, возвращаем её. Если нет, то создаем новую через функцию createQuery.

Мы научились создавать абстракции, которые сами в себе хранят состояние, обновляют его и выполняют запросы. Также теперь умеем инкапсулировать всё query в отдельную сущность, чтобы потом переиспользовать.Теперь попробуем соединить всё воедино.

Получаем данные в useQuery с использованием query

Перепишем содержимое хука useQuery:

export const useQuery = ({ queryKey, queryFn, staleTime }) => {
  const client = useQueryClient()
  const queryRef = useRef(null)

  if (!queryRef.current) {
    queryRef.current = client.getQuery({ queryKey, queryFn, staleTime })
  }

  useEffect(() => {
    queryRef.current.fetch()
  }, [])

  return queryRef.current.state
}

Для этого получим instance QueryClient, в которое мы обернули наше приложение, и создадим ссылку, которая будет хранить в себе нужную нам query. Логика такая: сначала мы инициализируем ссылку, которая хранит в себе query. Затем вызываем fetch, но уже не руками, а через query.

Убираем код, который больше не нужен, и возвращаем наши данные не из локального состояния, а из состояния query. На этом моменте всё работает: у нас инициализируется query, отправляются запросы, но UI не знает о том, что мы что-то изменили.

Если кто-то писал на классовых компонентах, то помнит про метод forceUpdate. Сейчас мы сделаем нечто похожее.

export const useQuery = ({ queryKey, queryFn, staleTime }) => {
  const client = useQueryClient()
  const queryRef = useRef(null)
  const [, rerender] = useReducer((v) => v + 1, 0)

  if (!queryRef.current) {
    queryRef.current = client.getQuery({ queryKey, queryFn, staleTime })
  }

  useEffect(() => {
    queryRef.current.fetch().finally(rerender)
  }, [])

  return queryRef.current.state
}

Добавляем простой счетчик, который будет увеличиваться каждый раз, когда мы его вызываем, и тем самым перерендеривать интерфейс.

Добавляем дедупликацию запросов

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

Дедуплицируем запросы. Для этого, когда мы начинаем запрос, блокируем возможность для следующего запросы у этой query, пока предыдущий не завершился. Только когда предыдущий запрос завершается, мы предоставляем возможность для перезапроса.

fetch: async () => {
  if (!query.promise) {
    query.promise = (async () => {
      query.setState((oldState) => ({
        ...oldState,
        error: undefined
      }))

      try {
        const data = await queryFn({ queryKey })

        query.setState((oldState) => ({
          ...oldState,
          status: "success",
          lastUpdated: Date.now(),
          data,
        }))
      } catch (error) {
        query.setState((oldState) => ({
          ...oldState,
          status: "error",
          error,
        }))
      } finally {
        query.promise = null
      }
    })()
  }

  return query.promise
}

Как это реализовать: добавляем новое поле promise, затем в fetch-функции проверяем, что мы еще не сделали запрос. Присваиваем в переменную promise результат самовызывающейся асинхронной функции. Вернем из метода fetch наш query.promise. В методе finally скажем о том, что можно делать новые запросы.

Когда мы вызываем fetch-функцию, проверяем, что нет исходящих запросов.

  • Если запросов нет, то мы начинаем делать запрос и присваиваем promise в эту переменную. Дальше, когда мы будем вызывать fetch-функцию, она будет следить за этим условием.
  • Если уже существует исходящий запрос, то есть в promise что-то есть, fetch-функция не будет идти дальше и делать новый запрос.

Когда предыдущий запрос у нас отрабатывает, независимо от того, успешно или не очень, в методе finally мы зануляем переменную promise, тем самым разблокируя возможность для совершения будущих запросов.

В результате у нас загружается список постов и конкретный пост и отправляется по одному запросу.

Отслеживаем изменения через подписку на query

Сейчас у нас интерфейс перерисовывается только тогда, когда мы делаем запрос. Если подумать, у query может быть собственный жизненный цикл: мы можем удалять или обновлять кэш через тайм-аут, и не хочется каждый раз руками вызывать fetch, чтобы перерендерить наш интерфейс.

Мы можем использовать паттерн Subscriber. В таком случае query будет создателем событий, а UI — подписчиком на события. Каждый раз, когда query меняет состояние, мы уведомляем UI и он обновляется. Чтобы реализовать эту логику, добавим массив подписчиков в query.

promise: null,
subscribers: [],
setState: (updaterFn) => {
  query.state = updaterFn(query.state)
  query.subscribers.forEach((subscriber) => subscriber.notify)
},

Каждый раз, когда у нас будет меняется состояние, алгоритм проходится по подписчикам и уведомляет их.

subscribe: (subscriber) => {
  query.subscribers.push(subscriber)

  return () => {
    query.subscribers = query.subscribers.filter((v) => v !== subscriber)
  }
},

Допишем метод для того, чтобы подписываться на query. Нового подписчика добавляем через push, а в ответ возвращаем функцию, которая будет отписывать от query через фильтр.

Таким образом, мы организовали массив подписчиков. Каждый раз, когда меняется состояние, мы уведомляем их об изменении. Также можно подписываться и отписываться от текущей query.

useEffect(() => {
  queryRef.current.subscribe({ notify: rerender })
  queryRef.current.fetch()
}, [])

return queryRef.current.state

Чтобы применить это в коде, нужно изменить всего лишь две строчки. В качестве функции, которая будет вызываться при изменении состояния, передаем render, а дальше просто руками вызываем fetch.

План выполнен, всё работает. Мы разобрались, что React-Query нестрашный, а магии снова не существует. Она где-то в другом месте, и в следующий раз мы обязательно до неё дойдем.