Не секрет, что я люблю 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 (
<div>
{ error ? (
<div>Error: {error.message}</div>
) : (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</div>
))}
</ul>
)}
</div>
)
Если мы сначала проверим данные, у нас возникнет та же проблема, если второй запрос завершится неудачно. Если мы всегда отображаем и ошибку, и данные, мы также потенциально отображаем устаревшую информацию.
Чтобы это исправить, нам нужно сбросить локальное состояние при изменении категории:
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.StrictMode>
, 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
, направьте его в запрос, и запросы будут автоматически прерываться при изменении категории.
На этом всё, вы можете поддержать автора оригинала статьи в твиттере. Это мой первый перевод, поэтому если у вас есть вопросы по оформлению или содержанию статьи пишите в комментариях.
Комментарии (11)
alexshipin
09.04.2024 12:32+1Спасибо за пример, и небольшое пояснение.
Смотря на то, какой именно код вы используете в своём примере, сугубо по моему личному мнению, есть подозрение, что данный пример сильно "притянут за уши". И, на основе личного опыта, есть вопрос: а почему нельзя использовать useEffect на каждое из состояний в отдельности?
Пояснение вопроса: у нас есть несколько состояний нашего условного компонента, и каждое состояние, по написанной логике, отвечает за определенное поведение компонента, и мне не очень понятно, почему по одному изменению пропса должны меняться все состояния в совокупности разом, если каждое из данных состояний должно порождать то или иное дальнейшее поведение. То есть: category -> порождает обновление ссылки -> ссылка порождает fetch -> результат ответа (response / catch) на запрос порождает изменение либо data с обнулением error, либо error с обнулением data -> наличие которых уже порождает изменение isLoading, что в свою очередь позволяет держать актуальные состояния и ошибки в текущем моменте, а не держать их из "прошлого".
inogdavsegda Автор
09.04.2024 12:32+2Да, можно добиться того же результата без библиотеки, но это гораздо больше кода, поэтому я привела преимущества. Спасибо за развёрнутый ответ.
gun_dose
09.04.2024 12:32+3Глядя в эти примеры, прихожу к выводу, что тут скорее напрашивается кастомный хук, нежели сторонняя библиотека.
inogdavsegda Автор
09.04.2024 12:32+3эта статья для того чтобы вам захотелось попробовать React Query, внутри библиотеки ещё много крутых и полезных функций
Ohar
09.04.2024 12:32+2Тут, кажется, фишка в том, что это довольно стандартный флоу, и писать под него каждый раз кастомный хук...
gun_dose
09.04.2024 12:32Один раз написал, а потом копируй из проекта в проект. И при командной работе это удобнее, т.к. нажать Ctrl+click и разобраться в 20 строчках кастомного хука - это сильно проще и быстрее, чем искать в интернете страницу незнакомого npm-пакета и изучать его документацию.
alex_k777
Зачем мне React Query, когда есть RTK Query?
inogdavsegda Автор
вы правы, но RTK нет смысла использовать без связки с Redux и в целом очень похожий функционал