[Перевод] Почему вам необходим React Query

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

Однако иногда я встречаю сообщения, в которых утверждается, что он вам не нужен для чего-то столь «простого», как получение данных с сервера.

Нам не нужны все эти дополнительные функции из React Query и мы не хотим добавлять стороннюю библиотеку, когда мы можем так же легко сделать fetch-запрос в useEffect.

В какой-то степени это правда: React Query предоставляет вам множество функций, таких как кэширование, повторные попытки, опрос, синхронизация данных, предзагрузка… и еще около миллиона, которые выходят далеко за рамки этой статьи. Ничего страшного, если они вам не нужны, но я все же думаю, что это не помешает вам использовать React Query.

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

function Bookmarks({ category }) {
  const [data, setData] = useState([])
  const [error, setError] = useState()

  useEffect(() => {
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => setData(d))
      .catch(e => setError(e))
  }, [category])

  //JSX на основе данных и состояния ошибки
}

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

332297d5305ed120fa253e81f3d5a23d.png

Посмотрите минуту или две и попробуйте найти их все. Я подожду…

Подсказка: это не массив зависимостей. С ним всё в порядке.

1. Гонка состояний

Есть причины, по которым официальная документация React рекомендует использовать для запросов либо фреймворк, либо библиотеку, такую как React Query. И хотя сам запрос данных может быть довольно тривиальным занятием, предсказуемый доступ к его состоянию в вашем приложении таковым не является.

useEffect здесь настроен таким образом, что он делает повторные запросы при каждом изменении category, что, безусловно, правильно. Однако ответы на эти запросы могут поступать в другом порядке, чем вы их отправили. Поэтому, если вы измените категорию с books на movies и ответ на movies придет раньше ответа на books, вы получите неправильные данные в своем компоненте.

604211c90e5b65c85747dfc67c68f264.png

В конце вы останетесь с противоречивым состоянием: в вашем локальном состоянии будет указано, что у вас выбраны movies, но данные, которые вы визуализируете, на самом деле будут являться books.

В документации React сказано, что мы можем исправить это с помощью функции очистки и булевого значения ignore, поэтому давайте сделаем это:

function Bookmarks({ category }) {
  const [data, setData] = useState([])
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  //JSX на основе данных и состояния ошибки
}

Теперь происходит следующее: функция очистки эффекта запускается при изменении категории, устанавливая локальный флаг ignoreв значение true. Если после этого придет ответ на запрос, setState больше не будет вызываться. Всё просто.

2. Состояние загрузки

Его тут вообще нет. У нас нет возможности показать UI загрузки интерфейса во время выполнения запросов — ни для первого, ни для последующих запросов. Итак, добавим это.

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState([])
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  //JSX на основе данных и состояния ошибки
}

3. Пустое состояние

Инициализация данных пустым массивом кажется хорошей идеей, чтобы избежать необходимости постоянно проверять undefined. Но что, если мы получим данные для категории, в которой еще нет записей, и на самом деле получим пустой массив? У нас нет возможности отличить «пока нет данных» от «нет данных вообще». Состояние загрузки, которое мы только что представили, помогает, но все же лучше инициализировать с помощью undefined:

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState()
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // JSX на основе данных и состояния ошибки
}

4. Данные и ошибки не сбрасываются при смене категории

И данные, и ошибка — это отдельные переменные, и они не сбрасываются при изменении категории. Это означает, что если одна категория вернётся с ошибкой и мы переключимся на другую, с успешным результатом запроса, наше состояние будет:

data: dataFromCurrentCategory
error: errorFromPreviousCategory

Результат будет зависеть от того, как мы рендерим JSX на основе этого состояния. Если мы сначала проверяем наличие ошибки, мы отобразим пользовательский интерфейс ошибки со старым сообщением, даже если у нас есть действительные данные:

return (
  
{ error ? (
Error: {error.message}
) : (
    {data.map(item => (
  • {item.name}
))} )}
)

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

Чтобы это исправить, нам нужно сбросить локальное состояние при изменении категории:

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState()
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
          setError(undefined)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
          setData(undefined)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // JSX на основе данных и состояния ошибки
}

5. Двойной запрос при StrictMode 

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

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

Бонус: обработка ошибок

Я не включил это в первоначальный список ошибок, потому что у вас возникнет та же проблема с React Query: fetch не отклоняется при ошибках HTTP, поэтому вам придется проверить res.ok и выдать ошибку самостоятельно. .

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState()
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      })
      .then(d => {
        if (!ignore) {
          setData(d)
          setError(undefined)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
          setData(undefined)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // JSX на основе данных и состояния ошибки
}

Наше маленькое «мы просто хотим получить данные, это же не должно быть сложно?» превратилось в огромную мешанину спагетти-кода, как только нам пришлось учитывать крайние случаи и управление состоянием. Так какой же здесь вывод?

Запрос данных это просто. Управление асинхронным состоянием — нет.

И здесь на помощь приходит React Query, потому что React Query — это НЕ библиотека для запросов, а менеджер состояний. Поэтому, когда вы говорите, что вам не нужен он для таких простых действий, как получение данных, вы на самом деле правы: даже с React Query вам нужно написать тот же fetch, что и раньше.

Но вам все равно нужен React Query, чтобы как можно проще сделать это состояние предсказуемым и доступным в вашем приложении. Потому что, будем честны, я не писал этот флаг ignore до того, как начал использовать React Query, и, вероятно, вы тоже.

С React Query код выше превращается в:

function Bookmarks({ category }) {
  const { isLoading, data, error } = useQuery({
    queryKey: ['bookmarks', category],
    queryFn: () =>
      fetch(`${endpoint}/${category}`).then((res) => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      }),
  })

  // JSX на основе данных и состояния ошибки
}

Это около 50% спагетти-кода, приведенного выше, и примерно столько же, сколько было в исходном фрагменте с ошибками. И да, это автоматически устраняет все ошибки, которые мы обнаружили:

Баги

  • Гонка состояний отсутствует, поскольку состояние всегда сохраняется по входным данным (category).

  • Вы сразу получаете состояния загрузки, данных и ошибок.

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

  • Вы не получите данные или ошибки из предыдущего запроса, если только вы этого не захотите.

  • Множественные выборки эффективно устраняются, включая те, которые исполняются из-за StrictMode.

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

Бонус: Отмена запроса

Многие в Твиттере упомянули об отсутствующей отмене запроса в исходном примере. Я не думаю, что это обязательно ошибка — просто отсутствующая функция. React Query также предлагает довольно простое решение:

function Bookmarks({ category }) {
  const { isLoading, data, error } = useQuery({
    queryKey: ['bookmarks', category],
    queryFn: ({ signal }) =>
      fetch(`${endpoint}/${category}`, { signal }).then((res) => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      }),
  })

  //JSX на основе данных и состояния ошибки
}

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

На этом всё, вы можете поддержать автора оригинала статьи в твиттере. Это мой первый перевод, поэтому если у вас есть вопросы по оформлению или содержанию статьи пишите в комментариях.

© Habrahabr.ru