Понимание того, как работать с вызовами API в веб-приложениях, является важным навыком. Существует множество различных библиотек, которые помогут вам в этом процессе, но иногда они не очень удобны для новичков.

При работе с ванильным JavaScript вы, вероятно, будете использовать библиотеку, такую ​​​​как Fetch или Axios, для выполнения запросов API. В React вы также можете их использовать, и сложность заключается в том, как организовать код вокруг этих библиотек, чтобы сделать его максимально читабельным, расширяемым и несвязанным.

Это не очень интуитивная задача. Очень часто новые разработчики, которые начинают работать с React, делают такие запросы к API:

// ❌ Don't do this

const UsersList = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("/users").then((data) => {
      setUsers(users);
    });
  }, []);

  return (
    <ul>
      {users.map(user => (
        <li>{user.name}<li>
      ))}
    </ul>
  );
};

Описанный выше подход работает и очень распространен даже в кодовых базах бизнес-уровня. Но есть и минусы его использования:

  • Данные хранятся в локальном состоянии
    .
    Каждый вызов API в других компонентах потребует нового локального useState
  • Библиотека запросов (Fetch) вызывается непосредственно в компоненте
    -
    Если поменять библиотеку на Axios, например, то каждый компонент нужно будет рефакторить
    - То же самое применяется к конечной точке, если она изменится, вам потребуется реорганизовать ее во многих местах.
  • Запрос на уровне сервера выполняется в презентационном компоненте
     –
    Компоненты предназначены для представления данных, а не для обработки логики выборки
     – рекомендуется иметь единую ответственность для каждого компонента, класса и функции
  • Неясно, что вернет запрос
    .
    Вы полагаетесь на имя конечной точки, чтобы узнать, что будет возвращено API.

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

Сценарий для нашего примера

Чтобы понять и склеить все концепции, давайте постепенно создадим приложение «Список продуктов». Приложение будет иметь следующие функции:

  • Список существующих элементов;
  • Добавить новый элемент;
  • Убрать предмет;
  • Отметить элемент как выполненный;

Для стилей я буду использовать TailwindCSS. Для моделирования запросов к API будет использоваться Mirage JS, очень простая в использовании и полезная библиотека для имитации API. Чтобы вызвать этот API, мы будем использовать Fetch.

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

Окончательный результат будет выглядеть так:

Создание конечных точек API

Этому приложению потребуются 4 конечные точки API:

  1. GET /api/grocery-list — Получить все предметы
  2. POST /api/grocery-list - Создать новый элемент
  3. PUT /api/grocery-list/:id/done - Пометить элемент с id равным :id как выполненный
  4. DELETE /api/grocery-list/:id — Удаляет элемент с идентификатором, равным :id

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

1. Получение всех предметов

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

// src/App.jsx

const App = () => {
  const [items, setItems] = useState([]);
  const [refetch, setRefetch] = useState(false);

  useEffect(() => {
    fetch("/api/grocery-list")
      .then((data) => data.json())
      .then((data) => {
        setItems(data.items);
      });
  }, [refresh]);

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
};

2. Создание нового элемента

Когда пользователь вводит заголовок элемента и нажимает кнопку «Добавить», приложение должно отправить вызов API для создания нового элемента, а затем снова получить все элементы, чтобы отобразить новый элемент:

// src/App.jsx

const App = () => {
  // ...
  const [title, setTitle] = useEffect("");

  const handleAdd = (event) => {
    event.preventDefault();

    fetch("/api/grocery-list", {
      method: "POST",
      body: JSON.stringify({ title }),
    }).then(() => {
      setTitle(""); // Empty the title input
      setRefresh(!refresh); // Force refetch to update the list
    });
  };

  return (
    // ...

    <form onSubmit={handleAdd}>
      <input
        required
        type="text"
        onChange={(event) => setTitle(event.target.value)}
        value={title}
      />
      <button type="submit">Add</button>
    </form>

    // ...
  );
};

3. Пометка элемента как выполненного

Когда пользователь нажимает на флажок, чтобы пометить элемент как выполненный, приложение должно отправить запрос PUT, передав item.id в качестве параметра на конечной точке. Если элемент уже помечен как выполненный, нам не нужно делать запрос.

Это очень похоже на создание нового элемента, только метод запроса меняется:

// src/App.jsx

const App = () => {
  // ...

  const handleMarkAsDone = (item) => {
    if (item.isDone) {
      return;
    }

    fetch(`/api/grocery-list/${item.id}/done`, {
      method: "PUT",
    }).then(() => {
      setRefresh(!refresh); // Force refetch to update the list
    });
  };

  return (
    // ...

    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <label>
            {/* Checkbox to mark the item as done */}
            <input
              type="checkbox"
              checked={item.isDone}
              onChange={() => handleMarkAsDone(item)}
            />
            {item.title}
          </label>
        </li>
      ))}
    </ul>

    // ...
  );
};

4. Удаление элемента

Это почти то же самое, что мы сделали, пометив элемент как выполненный, но с помощью метода DELETE. При нажатии на кнопку Удалить приложение должно вызвать функцию, которая отправляет вызов API:

// src/App.jsx

const App = () => {
  // ...

  const handleDelete = (item) => {
    fetch(`/api/grocery-list/${item.id}`, {
      method: "DELETE",
    }).then(() => {
      setRefresh(!refresh); // Force refetch to update the list
    });
  };

  return (
    // ...

    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <label>
            {/* Checkbox to mark the item as done */}
            <input type="checkbox" onChange={() => handleMarkAsDone(item)} />
            {item.title}
          </label>

          {/* Delete button */}
          <button onClick={() => handleDelete(item)}>Delete</button>
        </li>
      ))}
    </ul>

    // ...
  );
};

Окончательный код для первой части примера

Окончательный код должен выглядеть так:

// src/App.jsx

const App = () => {
  const [items, setItems] = useState([]);
  const [title, setTitle] = useState("");
  const [refresh, setRefresh] = useState(false);

  // Retrieve all the items
  useEffect(() => {
    fetch("/api/grocery-list")
      .then((data) => data.json())
      .then(({ items }) => setItems(items));
  }, [refresh]);

  // Adds a new item
  const handleAdd = (event) => {
    event.preventDefault();

    fetch("/api/grocery-list", {
      method: "POST",
      body: JSON.stringify({ title }),
    }).then(() => {
      setRefresh(!refresh);
      setTitle("");
    });
  };

  // Mark an item as done
  const handleMarkAsDone = (item) => {
    if (item.isDone) {
      return;
    }

    fetch(`/api/grocery-list/${item.id}/done`, {
      method: "PUT",
    }).then(() => {
      setRefresh(!refresh);
    });
  };

  // Deletes an item
  const handleDelete = (item) => {
    fetch(`/api/grocery-list/${item.id}`, {
      method: "DELETE",
    }).then(() => {
      setRefresh(!refresh);
    });
  };

  return (
    <>
      <form onSubmit={handleAdd}>
        <input
          required
          type="text"
          onChange={(event) => setTitle(event.target.value)}
          value={title}
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={item.isDone}
                onChange={() => handleMarkAsDone(item)}
              />
              {item.title}
            </label>
            <button onClick={() => handleDelete(item)}>delete</button>
          </li>
        ))}
      </ul>
    </>
  );
};

Первый рефакторинг: создание сервисов

Теперь, когда у нас уже все на месте и работает, давайте рефакторим код.

Первое, что мы можем сделать, чтобы улучшить код, — это создать сервис для вызовов API. Службы — это в основном функции JavaScript, которые отвечают за вызов API.

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

// src/services/grocery-list.js

const basePath = "/api/grocery-list";

export const getItems = () => fetch(basePath).then((data) => data.json());

export const createItem = (title) =>
  fetch(basePath, {
    method: "POST",
    body: JSON.stringify({ title }),
  });

export const markItemAsDone = (itemId) =>
  fetch(`${basePath}/${itemId}/done`, {
    method: "PUT",
  });

export const deleteItem = (itemId) =>
  fetch(`${basePath}/${itemId}`, {
    method: "DELETE",
  });

Обратите внимание, что сервисы возвращают Promise, а все вызовы состояния удалены. Мы также заменили повторяющийся базовый путь конечных точек API константой.

Теперь давайте заменим старые fetch вызовов компонента новыми сервисами:

// src/App.jsx

// Importing the services
import {
  createItem,
  deleteItem,
  getItems,
  markItemAsDone,
} from "./services/grocery-list";

const App = () => {
  // ...

  useEffect(() => {
    // Service call
    getItems().then(({ items }) => {
      setItems(items);
    });
  }, [refresh]);

  const handleAdd = (event) => {
    event.preventDefault();

    // Service call
    createItem(title).then(() => {
      setRefresh(!refresh);
      setTitle("");
    });
  };

  const handleMarkAsDone = (item) => {
    if (item.isDone) {
      return;
    }
    // Service call
    markItemAsDone(item.id).then(() => {
      setRefresh(!refresh);
    });
  };

  const handleDelete = (item) => {
    // Service call
    deleteItem(item.id).then(() => {
      setRefresh(!refresh);
    });
  };

  // ...
};

Это гораздо более читабельно и тестируемо. Вы можете тестировать каждую службу по отдельности, а не тестировать компонент в целом. Кроме того, гораздо проще понять, что должен делать код, например:

// Get the items, then set the items.
getItems().then(({ items }) => {
  setItems(items);
});

Второй рефакторинг: абстрагирование HTTP-вызова

Служба grocery-list сильно зависит от библиотеки Fetch. Если мы решим изменить его на Axios, все вызовы должны измениться. Кроме того, сервисный уровень не должен знать, как вызывать API, а должен знать только какой API. называться.

Чтобы не смешивать эти обязанности, мне нравится создавать адаптер API. Название на самом деле не имеет значения — цель здесь — иметь единое место, где настраиваются HTTP-вызовы API.

// src/adapters/api.js

const basePath = "/api";

const api = {
  get: (endpoint) => fetch(`${basePath}/${endpoint}`),
  post: (endpoint, body) =>
    fetch(`${basePath}/${endpoint}`, {
      method: "POST",
      body: body && JSON.stringify(body),
    }),
  put: (endpoint, body) =>
    fetch(`${basePath}/${endpoint}`, {
      method: "PUT",
      body: body && JSON.stringify(body),
    }),
  delete: (endpoint) =>
    fetch(`${basePath}/${endpoint}`, {
      method: "DELETE",
    }),
};

export { api };

Это единственный файл во всем приложении, который имеет дело с HTTP-вызовами. Другим файлам, которым нужно вызывать API, нужно вызывать только эти методы.

Теперь, если вы решите заменить Fetch на Axios, вы просто измените этот единственный файл, и все готово.

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

Говоря об услугах, давайте заменим старые вызовы fetch на новые вызовы api..

// src/services/grocery-list

import { api } from "../adapters/api";

const resource = "grocery-list";

export const getItems = () => api.get(resource).then((data) => data.json());

export const createItem = (title) => api.post(resource, { title });

export const markItemAsDone = (itemId) => api.put(`${resource}/${itemId}/done`);

export const deleteItem = (itemId) => api.delete(`${resource}/${itemId}`);

Вау, намного чище! Обратите внимание, что некоторые обязанности, которые находятся на уровне запроса, больше не здесь, например, преобразование объекта JSON в строку. Это не входило в обязанности сервисов, и теперь этим занимается уровень API.

Опять же, код стал более читабельным и тестируемым.

Третий рефакторинг: создание хуков

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

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

Первый хук, который мы собираемся создать, — это useGetGroceryListItems(), который содержит вызов API getItems().

// src/hooks/grocery-list.js

// Default module import
import * as groceryListService from "../services/grocery-list";

export const useGetGroceryListItems = () => {
  const [items, setItems] = useState([]);
  const [refresh, setRefresh] = useState(false);

  useEffect(() => {
    groceryListService.getItems().then(({ items }) => {
      setItems(items);
    });
  }, [refresh]);

  const refreshItems = () => {
    setRefresh(!refresh);
  };

  return { items, refreshItems };
};

Обратите внимание, что мы в основном скопировали поведение, которое ранее было в компоненте, в новый хук. Нам также нужно было создать refreshItems(), чтобы мы могли обновлять данные, когда захотим, вместо того, чтобы снова вызывать службу напрямую.

Мы также импортируем служебный модуль, чтобы использовать его как groceryListService.getItems() вместо вызова только getItems(). Это связано с тем, что наши хуки будут иметь похожие имена функций, поэтому, чтобы избежать конфликтов, а также улучшить читаемость, импортируется весь сервисный модуль.

Теперь давайте создадим остальные хуки для других функций (создание, обновление и удаление).

// src/hooks/grocery-list.js

export const useCreateGroceryListItem = () => {
  const createItem = (title) => groceryListService.createItem(title);

  return { createItem };
};

export const useMarkGroceryListItemAsDone = () => {
  const markItemAsDone = (item) => {
    if (item.isDone) {
      return;
    }
    groceryListService.markItemAsDone(item.id);
  };

  return { markItemAsDone };
};

export const useDeleteGroceryListItem = () => {
  const deleteItem = (item) => groceryListService.deleteItem(item.id);

  return { deleteItem };
};

Затем нам нужно заменить сервисные вызовы хуками в компоненте.

// src/App.jsx

// Hooks import
import {
  useGetGroceryListItems,
  useCreateGroceryListItem,
  useMarkGroceryListItemAsDone,
  useDeleteGroceryListItem,
} from "./hooks/grocery-list";

const App = () => {
  // ...
  const { items, refreshItems } = useGetGroceryListItems();
  const { createItem } = useCreateGroceryListItem();
  const { markItemAsDone } = useMarkGroceryListItemAsDone();
  const { deleteItem } = useDeleteGroceryListItem();

  // ...

  const handleMarkAsDone = (item) => {
    // Validation moved to hook and passing `item` instead of `item.id`
    markItemAsDone(item).then(() => refreshItems());
  };

  const handleDelete = (item) => {
    // Passing `item` instead of `item.id`
    deleteItem(item).then(() => refreshItems());
  };

  // ...
};

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

Если вы используете решение для управления состоянием, такое как Redux, Context API или Zustand, например, вы можете вносить изменения в состояние внутри хуков, а не вызывать их на уровне компонентов. Это помогает прояснить ситуацию и очень хорошо распределить обязанности.

Последний рефакторинг: добавление состояния загрузки

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

После добавления состояния загрузки к каждому хуку файл будет выглядеть так:

// src/hooks/grocery-list.js

export const useGetGroceryListItems = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state
  const [items, setItems] = useState([]);
  const [refresh, setRefresh] = useState(false);

  useEffect(() => {
    setIsLoading(true); // Adding loading state
    groceryListService.getItems().then(({ items }) => {
      setItems(items);
      setIsLoading(false); // Removing loading state
    });
  }, [refresh]);

  const refreshItems = () => {
    setRefresh(!refresh);
  };

  return { items, refreshItems, isLoading };
};

export const useCreateGroceryListItem = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state

  const createItem = (title) => {
    setIsLoading(true); // Adding loading state
    return groceryListService.createItem(title).then(() => {
      setIsLoading(false); // Removing loading state
    });
  };

  return { createItem, isLoading };
};

export const useMarkGroceryListItemAsDone = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state

  const markItemAsDone = (item) => {
    if (item.isDone) {
      return;
    }

    setIsLoading(true); // Adding loading state
    return groceryListService.markItemAsDone(item.id).then(() => {
      setIsLoading(false); // Removing loading state
    });
  };

  return { markItemAsDone, isLoading };
};

export const useDeleteGroceryListItem = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state

  const deleteItem = (item) => {
    setIsLoading(true); // Adding loading state
    return groceryListService.deleteItem(item.id).then(() => {
      setIsLoading(false); // Removing loading state
    });
  };

  return { deleteItem, isLoading };
};

Теперь нам нужно подключить состояние загрузки страницы к каждому хуку:

// src/App.jsx

const App = () => {
  // ...

  // Getting loading states and renaming to avoid conflicts
  const { items, refreshItems, isLoading: isFetchingItems } = useGetGroceryListItems();
  const { createItem, isLoading: isCreatingItem } = useCreateGroceryListItem();
  const { markItemAsDone, isLoading: isUpdatingItem } = useMarkGroceryListItemAsDone();
  const { deleteItem, isLoading: isDeletingItem } = useDeleteGroceryListItem();

  // Read each loading state and convert them to a component-level value
  const isLoading = isFetchingItems || isCreatingItem || isUpdatingItem || isDeletingItem;

  // ...

  return (
    <>
      <form onSubmit={handleAdd}>
        <input
          required
          type="text"
          onChange={(event) => setTitle(event.target.value)}
          value={title}
          disabled={isLoading} {/* Loading State */}
        />
        <button type="submit" disabled={isLoading}> {/* Loading State */}
          Add
        </button>
      </form>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={item.isDone}
                onChange={() => handleMarkAsDone(item)}
                disabled={isLoading} {/* Loading State */}
              />
              {item.title}
            </label>
            <button onClick={() => handleDelete(item)} disabled={isLoading}> {/* Loading State */}
              delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
};

Бонусный рефакторинг: создайте утилиту

Обратите внимание, что в хуке useMarkGroceryListItemAsDone() у нас есть логика, которая сообщает, должен ли элемент обновляться или нет:

// src/hooks/grocery-list.js

const markItemAsDone = (item) => {
  if (item.isDone) {
    return; // Don't call the service
  }

  // Call the service and update the item

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

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

// src/utils/grocery-list.js

export const shouldUpdateItem = (item) => !item.isDone;

А затем вызовите эту утилиту в хуке:

export const useMarkGroceryListItemAsDone = () => {
  // ...

  const markItemAsDone = (item) => {
    // Calling the util
    if (!shouldUpdateItem(item)) {
      return;
    }

    // ...

Теперь хуки не зависят от какой-либо логики, связанной с бизнесом: они просто вызывают функции и возвращают их значения.

Подведение итогов

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

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

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

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

Вот и все! Надеюсь, вы узнали что-то новое сегодня, чтобы сделать ваше путешествие по программированию еще лучше!

Если у вас есть какие-либо отзывы или предложения, отправьте мне письмо

Отличное кодирование!