[Перевод] Почему вам необходим 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 строках кода.
Посмотрите минуту или две и попробуйте найти их все. Я подожду…
Подсказка: это не массив зависимостей. С ним всё в порядке.
1. Гонка состояний
Есть причины, по которым официальная документация React рекомендует использовать для запросов либо фреймворк, либо библиотеку, такую как React Query. И хотя сам запрос данных может быть довольно тривиальным занятием, предсказуемый доступ к его состоянию в вашем приложении таковым не является.
useEffect
здесь настроен таким образом, что он делает повторные запросы при каждом изменении category
, что, безусловно, правильно. Однако ответы на эти запросы могут поступать в другом порядке, чем вы их отправили. Поэтому, если вы измените категорию с books
на movies
и ответ на movies
придет раньше ответа на books
, вы получите неправильные данные в своем компоненте.
В конце вы останетесь с противоречивым состоянием: в вашем локальном состоянии будет указано, что у вас выбраны 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
, направьте его в запрос, и запросы будут автоматически прерываться при изменении категории.
На этом всё, вы можете поддержать автора оригинала статьи в твиттере. Это мой первый перевод, поэтому если у вас есть вопросы по оформлению или содержанию статьи пишите в комментариях.