Запросы, хуки и спагетти
Привет, Хабр!
Во время разработки веб-приложений мы порой делаем запросы на сервер внутри useEffect прямо в компоненте с визуальным составляющим. Однако не всегда очевидно, что смешивание логики с интерфейсом может привести к усложнению кодовой базы.
В этой статье мы рассмотрим, как неправильное разделение ответственности может негативно сказаться на ваших компонентах, и какие подходы помогут избежать подобных проблем.
Основные проблемы отправки запросов внутри useEffect прямо в компонентах:
Сложно отслеживать состояние, так как различные запросы могут зависеть от разных состояний компонента. Это требует внимательности при обработке изменений состояния, чтобы избежать нежелательных повторных запросов.
Нарушается принцип разделения ответственности, когда логика запросов смешивается с логикой компонента. Это может сделать код менее читаемым и увеличить связанность между разными частями компонента.
Рассмотрим код со смешанной с визуалом логикой, попытаемся сделать его более лаконичным:
/YourComponent.jsx
import React, { useEffect, useState } from 'react';
import axios from 'axios';
const YourComponent = () => {
const [todoId] = useState('1');
const [todoRequest, setTodoRequest] = useState({
data: null,
isLoading: false,
isError: false
});
const [postId] = useState('1');
const [postsRequest, setPostsRequest] = useState({
data: null,
isLoading: false,
isError: false
});
useEffect(() => {
const fetchTodos = async () => {
try {
setTodoRequest((prev)=>({...prev, isLoading: true}));
const response = await axios.get(`https://jsonplaceholder.typicode.com/todos/${todoId}`);
setTodoRequest((prev)=>({...prev, data: response.data, isLoading: false}));
} catch (error) {
setTodoRequest((prev)=>({...prev, isLoading: false, isError: true}));
}
};
fetchTodos();
}, [todoId]);
useEffect(() => {
const fetchPosts = async () => {
try {
setPostsRequest((prev)=>({...prev, isLoading: true}));
const response = await axios.get(`https://jsonplaceholder.typicode.com/posts/${postId}`);
setPostsRequest((prev)=>({...prev, data: response.data, isLoading: false}));
} catch (error) {
setPostsRequest((prev)=>({...prev, isLoading: false, isError: true}));
}
};
fetchPosts();
}, [postId]);
return (
<div>
{todoRequest.isLoading && <p>Loading...</p>}
<pre>{todoRequest.data && JSON.stringify(todoRequest.data, null, 2)}</pre>
{todoRequest.isError && <h1>Не удалось загрузить задачи</h1>}
{postsRequest.isLoading && <p>Loading...</p>}
<pre>{postsRequest.data && JSON.stringify(postsRequest.data, null, 2)}</pre>
{postsRequest.isError && <h1>Не удалость загрузить статьи</h1>}
</div>
);
};
export default YourComponent;
Код выглядит достаточно нагружено, и с увеличением количества запросов с различными параметрами будет всё больше походить на спагетти.
Как улучшить читаемость кода не избавляясь от useEffect?
Выносить всю логику отправки запросов в хуки!
Для более легкой поддержки и улучшения читаемости кода нужно как минимум вынести логику запросов в хуки, отделенные от компонента, соблюдая принцип разделения ответственности.
В коде выше довольно много повторяющегося кода, вынесем отслеживание состояния запроса в отдельный хук — useFetch:
/hooks/useFetch.jsx
import { useState, useEffect } from "react";
export const useFetch = ({ requestDeps, requestFn }) => {
const [request, setRequest] = useState({
data: null,
isLoading: false,
isError: false,
});
useEffect(() => {
const fetchPosts = async () => {
try {
setRequest((prev) => ({ ...prev, isLoading: true }));
const response = await requestFn();
setRequest((prev) => ({
...prev,
data: response.data,
isLoading: false,
}));
} catch (error) {
console.log(error);
setRequest((prev) => ({
...prev,
isLoading: false,
isError: true,
}));
}
};
fetchPosts();
}, [...requestDeps]);
return {
data: request.data,
isLoading: request.isLoading,
isError: request.isError,
};
};
useFetch принимает в себя функцию с запросом, и возвращает объект с:
Данными, возвращенными функцией-запросом
Состоянием ожидания ответа
Состоянием выполнения запроса с ошибкой
Теперь, взяв за основу useFetch, напишем хуки для получения поста и задачи:
/hooks/usePost.jsx
import { useFetch } from "./useFetch";
import axios from "axios";
export const usePost = ({ postId }) => {
return useFetch({
requestDeps: [postId],
requestFn: async () => {
const data = await axios.get(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
);
return data;
},
});
};
/hooks/useTodo.jsx
import { useFetch } from "./useFetch";
import axios from "axios";
export const useTodo = ({ todoId }) => {
return useFetch({
requestDeps: [todoId],
requestFn: async () => {
const data = await axios.get(
`https://jsonplaceholder.typicode.com/todos/${todoId}`,
);
return data;
},
});
};
Теперь используем наши хуки в компоненте!
/YourComponent.jsx
import React from "react";
import { usePost } from "./hooks/usePost";
import { useTodo } from "./hooks/useTodo";
const YourComponent = () => {
const {
data: postData,
isLoading: isPostLoading,
isError: isPostFailed,
} = usePost({ postId: "1" });
const {
data: todoData,
isLoading: isTodoLoading,
isError: isTodoFailed,
} = useTodo({ todoId: "1" });
return (
<div>
{isTodoLoading && <p>Loading...</p>}
<pre>{todoData && JSON.stringify(todoData, null, 2)}</pre>
{isTodoFailed && <h1>Не удалось загрузить задачи</h1>}
{isPostLoading && <p>Loading...</p>}
<pre>{postData && JSON.stringify(postData, null, 2)}</pre>
{isPostFailed && <h1>Не удалость загрузить статьи</h1>}
</div>
);
};
export default YourComponent;
Кода в самом компоненте стало в 2 раза меньше, и логика запросов никак не мешается с интерфейсом.
Использование React Query для отправки запросов
React Query - это библиотека, предназначенная для управления состоянием данных в приложении. Она упрощает выполнение запросов к серверу, управление кэшированием данных и обеспечивает интуитивные средства для обработки состояний загрузки и ошибок.
Руководство по установке: https://tanstack.com/query/latest/docs/framework/react/installation
React Query также освобождает нас от написания запросов в useEffect и даёт обширный набор инструментов взаимодействия с состоянием и валидацией запросов.
Перепишем наш код уже с использованием React Query:
useFetch удаляем за ненадобностью, useQuery позаботится за отслеживание состояния запроса вместо нас.
В корневом файле создадим клиент react-query:
/App.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import YourComponent from "./app/YourComponent";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10000,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourComponent />
</ QueryClientProvider>
);
}
Внутри QueryClient мы можем задать базовые настройки, которые будут по умолчанию использовать все запросы useQuery. В данном случае мы задали по умолчанию staleTime равный 10000.
staleTime - время в миллисекундах, через которое данные считаются устаревшими. При надобности, мы можем прописать отдельным хукам свои значения staleTime.
Подробнее о QueryClient: https://tanstack.com/query/latest/docs/reference/QueryClient
Используем useQuery в наших хуках:
/hooks/usePost.jsx
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
export const usePost = ({ postId }) => {
return useQuery({
queryKey: ["post", postId],
queryFn: async () => {
const { data } = await axios.get(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
);
return data;
},
});
};
/hooks/useTodo.jsx
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
export const useTodo = ({ todoId }) => {
return useQuery({
queryKey: ["todo", todoId],
queryFn: async () => {
const { data } = await axios.get(
`https://jsonplaceholder.typicode.com/todos/${todoId}`,
);
return data;
},
});
};
Компонент YourComponent не поменялся, так как useFetch возвращал похожие на useQuery поля.
Подробнее о useQuery: https://tanstack.com/query/latest/docs/framework/react/reference/useQuery
Пример с использованием invalidateQueries в React Query
Представим ситуацию, в которой после отправки POST запроса с созданием поста нам нужно обновить список всех постов.
Данный функционал можно реализовать с помощью React Query не прибегая к использованию useEffect в компоненте.
Создадим хук usePosts, через который будем получать посты:
/hooks/usePosts.jsx
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
export const usePosts = () => {
return useQuery({
queryKey: ["posts"],
queryFn: async () => {
const { data } = await axios.get(
`https://jsonplaceholder.typicode.com/posts/`,
);
return data;
},
});
};
Создадим хук useCreatePost, с помощью которого будем создавать посты:
/hooks/useCreatePost.jsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
export const useCreatePost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["create-post"],
mutationFn: async ({ postTitle }) => {
const { data } = await axios.post(
`https://jsonplaceholder.typicode.com/posts`,
{
title: postTitle,
},
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
};
useMutation - хук, обеспечивающий удобный способ выполнения мутаций, таких как создание, обновление или удаление данных, и автоматическое управление состоянием загрузки, ошибок и результата мутаций.
queryClient.invalidateQueries используется для инвалидации (очистки) кэшированных данных для указанных запросов. При вызове этой функции React Query удаляет данные из кэша для указанных запросов, что приводит к их повторному выполнению при следующем обращении к данным. Довольно удобный механизм для обновления кэшированных данных, обеспечивающий актуальность информации. В массив queryKey передаём ключь запроса, который хотим инвалидировать после мутации.
Подробнее о useMutation: https://tanstack.com/query/latest/docs/framework/react/reference/useMutation
Подробнее о queryClient.invalidateQueries: https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientinvalidatequeries
Используем только что созданные хуки в компоненте:
/YourComponent.jsx
import React, { useState } from "react";
import { useCreatePost } from "./hooks/useCreatePost";
import { usePosts } from "./hooks/usePosts";
const YourComponent = () => {
const [postTitle, setPostTitle] = useState("");
const {
data: postsData,
isLoading: isPostsLoading,
isError: isPostsFailed,
} = usePosts();
const createPost = useCreatePost();
const submitPost = () =>
createPost.mutate({
postTitle,
});
return (
<div>
<input value={postTitle} onChange={(e) => setPostTitle(e.target.value)} />
<button onClick={submitPost} disabled={!postTitle.length}>
Создать пост
</button>
{isPostsLoading && <p>Loading...</p>}
<pre>{postsData && JSON.stringify(postsData, null, 2)}</pre>
{isPostsFailed && <h1>Не удалость загрузить статьи</h1>}
</div>
);
};
export default YourComponent;
Отправка запросов с помощью хука use в React 19
В новой версии React появится возможность делать запросы без использования useEffect!
Пример использования use из документации:
import { use } from 'react';
function MessageComponent({ messagePromise }) {
const message = use(messagePromise);
const theme = use(ThemeContext);
// ...
Подробнее про use можно почитать в официальной документации.
Итоги
Внимательно планируйте и структурируйте ваши компоненты, чтобы избежать спагетти и кучи ошибок. Поддерживайте чистоту кода, разделяйте логику, группируйте компоненты по функциональности и следите за четкими зависимостями между ними.
Спасибо за внимание!