Работа с API: загрузка, ошибки, состояние

Загружаем данные с сервера правильно: учитываем три состояния — идёт загрузка, пришли данные, случилась ошибка — и выносим всё в переиспользуемый composable.

Три состояния запросаloading (ждём ответ), data (успех) и error (сбой). Хороший интерфейс показывает каждое из них, а не «зависает» без объяснений.

Наивный и правильный подход

Новичок часто просто кладёт результат fetch в переменную и забывает про загрузку и ошибки. Тогда пользователь видит пустой экран, не понимая, грузится оно или сломалось. Правильно — завести три реактивные переменные и обновлять их по ходу запроса:

<template>
  <p v-if="loading">Загрузка...</p>
  <p v-else-if="error">Ошибка: {{ error }}</p>
  <ul v-else>
    <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
  </ul>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const posts = ref([])
const loading = ref(true)
const error = ref(null)

onMounted(async () => {
  try {
    const res = await fetch('https://api.example.com/posts')
    if (!res.ok) throw new Error('Статус ' + res.status)
    posts.value = await res.json()
  } catch (e) {
    error.value = e.message
  } finally {
    loading.value = false   // снимаем загрузку в любом случае
  }
})
</script>

Связка v-if / v-else-if / v-else из раздела 2 показывает ровно одно из трёх состояний. finally гарантирует, что loading снимется и при успехе, и при ошибке.

Промоделируем три состояния

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

function loadData(shouldFail) {
  const state = { loading: true, data: null, error: null };
  try {
    if (shouldFail) throw new Error("сеть недоступна");
    state.data = ["Пост 1", "Пост 2"];
  } catch (e) {
    state.error = e.message;
  } finally {
    state.loading = false;
  }
  return state;
}

console.log("Успех:", loadData(false));
console.log("Ошибка:", loadData(true));

Вывод:

Успех: { loading: false, data: [ 'Пост 1', 'Пост 2' ], error: null }
Ошибка: { loading: false, data: null, error: 'сеть недоступна' }

Выносим в composable useFetch

Этот паттерн повторяется в каждом запросе, поэтому его выносят в composable из прошлого урока:

<!-- composables/useFetch.js -->
<script>
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(true)
  const error = ref(null)

  fetch(url)
    .then(res => res.json())
    .then(json => { data.value = json })
    .catch(e => { error.value = e.message })
    .finally(() => { loading.value = false })

  return { data, loading, error }
}
</script>

Теперь любой компонент пишет лаконично: const { data, loading, error } = useFetch('/api/posts') — и сразу получает все три состояния.

Чек-лист хорошего запроса

СостояниеЧто показать пользователю
loadingспиннер или «Загрузка...»
errorпонятное сообщение и кнопку «Повторить»
data (пусто)«Ничего не найдено», а не пустоту
data (есть)сам список/контент

Итог

  • У запроса три состояния: loading, data, error — покажите каждое.
  • fetch с async/await запускают в onMounted; finally снимает загрузку всегда.
  • v-if/v-else-if/v-else показывают ровно одно состояние.
  • Повторяющийся паттерн выносят в composable useFetch.
Проверьте себя
1. Какие три состояния обычно отслеживают при запросе к серверу?
Astart, middle, end
Bloading, data, error
Cget, post, put
Dopen, close, retry
2. Зачем снимать loading именно в блоке finally?
Afinally выполняется быстрее
Bfinally срабатывает и при успехе, и при ошибке, поэтому загрузка снимется в любом исходе
CИначе данные не загрузятся
DЭто требование Vue
3. Почему паттерн loading/data/error часто выносят в composable useFetch?
AЧтобы ускорить сеть
BПотому что этот код повторяется в каждом запросе, и composable избавляет от дублирования
CЭто обязательное требование fetch
DЧтобы избежать использования onMounted
Поддержать проект