Привет, Хабр!

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

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

Основные проблемы отправки запросов внутри useEffect прямо в компонентах:

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

  2. Нарушается принцип разделения ответственности, когда логика запросов смешивается с логикой компонента. Это может сделать код менее читаемым и увеличить связанность между разными частями компонента.

Рассмотрим код со смешанной с визуалом логикой, попытаемся сделать его более лаконичным:

/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 принимает в себя функцию с запросом, и возвращает объект с:

  1. Данными, возвращенными функцией-запросом

  2. Состоянием ожидания ответа

  3. Состоянием выполнения запроса с ошибкой

Теперь, взяв за основу 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 можно почитать в официальной документации.

Итоги

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

Спасибо за внимание!