Навигация

Data fetching в Nuxt: useFetch, useAsyncData и $fetch

В Nuxt за загрузку данных отвечают три инструмента: useFetch, useAsyncData и $fetch. Новичку легко запутаться: все трое «делают запрос», но нужны в разных ситуациях, а подводные камни (двойные запросы, потерянные cookies, устаревший кэш) проявляются только в проде.

Поэтому пойдём не по справочнику, а по шагам. Сначала разберёмся, какую проблему вообще решает Nuxt. Потом познакомимся с $fetch, затем с useAsyncData и useFetch, и только после этого — с реактивностью, ключами, ошибками, отменой запросов и кэшем. Каждая глава опирается на предыдущую, а в конце каждой есть короткий вывод, который стоит запомнить.

Содержание

  1. Почему в Nuxt нельзя просто вызвать fetch
  2. Знакомство: $fetch
  3. useAsyncData: данные переживают переход на клиент
  4. useFetch: тот же результат короче
  5. Реактивные запросы
  6. Ключи и дедупликация
  7. Загрузка, пустое состояние и ошибки
  8. Блокирует ли запрос навигацию: await, lazy, immediate
  9. Отмена запросов и гонки
  10. Cookies и авторизация при SSR
  11. Кэширование данных
  12. Что происходит с данными в пути
  13. Антипаттерны
  14. Подвохи на собеседовании
  15. Ключевые тезисы для интервью
  16. Заключение
  17. Полезные ссылки

Почему в Nuxt нельзя просто вызвать fetch

Чтобы понять тему, начнём с проблемы, из-за которой всё это существует.

В обычном SPA данные грузятся в браузере: страница открылась пустой, потом запрос, потом появились данные. В Nuxt иначе — страница сначала рендерится на сервере, чтобы отдать браузеру уже готовый HTML. Это нужно для SEO (поисковик видит контент) и для скорости восприятия (пользователь сразу видит содержимое).

Значит, чтобы данные попали в этот HTML, их надо получить на сервере. И тут появляется ловушка: код в <script setup> выполняется и на сервере, и на клиенте. Если просто вызвать запрос прямо там, он уйдёт дважды — сначала на сервере (для HTML), потом снова в браузере (когда страница «оживает»).

Представьте: сервер испёк страницу и упаковал её вместе с данными, но браузер об упаковке не знает и печёт всё заново.

<script setup>
// ❌ при SSR уйдёт дважды: на сервере и снова на клиенте
const data = await $fetch('/api/item')
</script>

Вывод: нужен инструмент, который получает данные на сервере и передаёт их браузеру готовыми, чтобы клиент не делал запрос повторно.

Запомнить: в Nuxt данные для первого рендера нужно получать через специальные composables, а не «сырым» запросом в setup — иначе двойная загрузка.


Знакомство: $fetch

Прежде чем строить решение, познакомимся с кирпичиком, из которого всё собрано.

$fetch — это обёртка над библиотекой ofetch, доступная и на сервере, и в браузере. Она умеет то, что обычно нужно от HTTP-клиента:

  • сама парсит JSON-ответ и сериализует тело запроса;
  • принимает headers, query, method, body;
  • поддерживает интерцепторы и отмену запросов.
// обычный случай: реакция на действие пользователя
async function submitForm(values) {
  const result = await $fetch('/api/contact', {
    method: 'POST',
    body: values,
  })
}

Здесь $fetch — правильный выбор, потому что такой запрос выполняется только в браузере, уже после загрузки страницы. Двойной загрузки не будет.

Но есть важное отличие от встроенного fetch: $fetch не переносит данные с сервера на клиент. Поэтому он не годится для первичной загрузки данных в setup — мы это увидели в прошлой главе.

Запомнить: $fetch — для действий (клик, отправка формы, мутация). Для данных, нужных при рендере страницы, он не подходит.


useAsyncData: данные переживают переход на клиент

Теперь построим решение. Идея такая: обернуть запрос в специальную функцию, которая выполняется на сервере, а её результат кладёт в payload — объект, встроенный прямо в HTML. Браузер вскрывает этот payload и получает данные, не делая запрос заново. Так снимается проблема двойной загрузки.

Эта функция называется useAsyncData. Разберём её по частям.

<script setup>
const { data: users, status, error } = await useAsyncData(
  'users',                          // 1. ключ (имя данных)
  () => $fetch('/api/users'),       // 2. функция, которая делает запрос
)
</script>

Три части вызова:

  1. Ключ — строковое имя для этих данных. Оно нужно, чтобы Nuxt различал разные наборы данных (подробнее в главе про ключи).
  2. Хендлер — функция, которая выполняет запрос. Внутри можно писать любую логику, а не только $fetch.
  3. Результат — реактивные data, status, error, а также функции refresh и clear.

Как это едет от сервера к клиенту:

  1. Сервер рендерит компонент и выполняет хендлер.
  2. Результат сериализуется и попадает в payload внутри HTML.
  3. Браузер при гидратации читает данные из payload и не делает повторный запрос.

useAsyncData — это больше, чем обёртка: он даёт сервер-совместимую загрузку, доставку данных в браузер и реактивное состояние сразу.

Запомнить: useAsyncData запускает запрос и переносит результат с сервера на клиент через payload, избавляя от повторной загрузки.


useFetch: тот же результат короче

Писать useAsyncData('users', () => $fetch('/api/users')) для простого GET-запроса каждый раз долго. Поэтому есть короткая форма — useFetch:

<script setup>
// эти две строки делают одно и то же
const { data } = await useFetch('/api/users')
const { data } = await useAsyncData('users', () => $fetch('/api/users'))
</script>

useFetch — это «сахар» над useAsyncData + $fetch. Он сам строит ключ, подставляет URL и отдаёт те же data, status, error, refresh, clear.

СитуацияИнструмент
Простой GET по известному URLuseFetch
Несколько запросов, своя логика, сторонний SDKuseAsyncData
Запрос по действию пользователя или мутация$fetch

Когда логика перестаёт быть «одним GET по URL» — переходите на useAsyncData. Например, нужно склеить два ответа:

<script setup>
const { data: dashboard } = await useAsyncData('dashboard', async (_nuxtApp, { signal }) => {
  const [users, orders] = await Promise.all([
    $fetch('/api/users', { signal }),
    $fetch('/api/orders', { signal }),
  ])
  return { users, orders }
})
</script>

Пара Promise.all здесь важна: независимые запросы идут параллельно, а не один за другим. Второй аргумент signal нужен для отмены запроса — о нём в главе про гонки.

Запомнить: useFetch — сокращение для простого GET; как только логика усложняется, берите useAsyncData.


Реактивные запросы

Пока что URL и данные были статичными. Но часто данные зависят от ввода пользователя, номера страницы или маршрута. Здесь есть три механизма, и их легко перепутать.

1. Реактивный query — основной способ. Если передать в query реактивное значение, Nuxt сам проследит за ним и перезапросит данные:

<script setup>
const page = ref(1)

// page меняется → запрос автоматически уходит заново
const { data: posts } = await useFetch('/api/posts', {
  query: { page },
})
</script>

2. Computed URL — когда URL собирается из значений.

<script setup>
const route = useRoute()

// при смене route.params.id URL пересобирается и запрос повторяется
const { data: user } = await useFetch(() => `/api/users/${route.params.id}`)
</script>

3. Опция watch — для зависимостей, не входящих в URL. И тут частый подвох: watch не пересобирает URL, он лишь перезапускает уже построенный запрос.

<script setup>
const id = ref(1)

// ❌ watch перезапустит запрос, но URL навсегда остался /api/users/1
const { data } = await useFetch(`/api/users/${id.value}`, { watch: [id] })
</script>

Если URL должен меняться — используйте computed URL или реактивный query, а не watch.

Запомнить: для динамических параметров берите реактивный query или computed URL; watch только перезапускает запрос и URL не меняет.


Ключи и дедупликация

В главе про useAsyncData ключ был «именем данных». Теперь раскроем, зачем он на самом деле.

Ключ — это идентификатор состояния. Одинаковый ключ означает: это одни и те же данные. Отсюда два следствия:

  • Дедупликация: если два компонента запрашивают данные с одним ключом, запрос выполнится один раз, а результат разделится между ними (общие data, status, error).
  • Кэш: по ключу данные можно достать из другого места и обновить (об этом в главе про кэш).

Теперь важный подвох. У useFetch ключ генерируется автоматически — из URL, опций и места вызова в коде. Значит, два useFetch с одним URL, но в разных компонентах — это разные ключи и два отдельных запроса:

<!-- ComponentA.vue -->
<script setup>
// ключ сгенерирован для этого места → отдельный запрос
const { data } = await useFetch('/api/random')
</script>

Чтобы компоненты делили данные, нужно передать одинаковый явный ключ:

<!-- тот же ключ в двух компонентах → один запрос, общие данные -->
<script setup>
const { data } = await useFetch('/api/random', { key: 'random' })
</script>

И правило для своих composables: если вы оборачиваете useAsyncData, всегда задавайте ключ явно. Иначе автоматический ключ совпадёт во всех местах вызова и состояния склеятся:

export function useCurrentUser() {
  return useAsyncData('current-user', () => $fetch('/api/me'))
}

Запомнить: ключ делает данные общими и дедуплицирует запросы; useFetch с одним URL в разных компонентах данные не делит — нужен явный key.


Загрузка, пустое состояние и ошибки

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

Главное, что нужно помнить: data до завершения запроса — это undefined, а не пустой массив. Поэтому в UI всегда есть три состояния: грузится, ошибка, данные. Удобнее всего опираться на status:

<script setup>
const { data: users, status, error } = await useFetch('/api/users')
</script>

<template>
  <p v-if="status === 'pending'">Загрузка...</p>
  <p v-else-if="error">Ошибка: {{ error.message }}</p>
  <ul v-else>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

status принимает четыре значения: idle (запрос ещё не начат), pending, success, error. Если не хочется обрабатывать undefined, задайте значение по умолчанию:

const { data: users } = await useFetch('/api/users', {
  default: () => [], // до загрузки data уже массив, v-for не сломается
})

Альтернатива: <Suspense>

Проверять status в каждом компоненте — не единственный путь. Vue и Nuxt умеют дожидаться асинхронных данных через <Suspense>: пока дочерний компонент «висит» на awaited-запросе, показывается запасной UI.

<!-- pages/users.vue: родитель отвечает только за состояние загрузки -->
<template>
  <Suspense>
    <UsersList />
    <template #fallback>
      <p>Загрузка...</p>
    </template>
  </Suspense>
</template>
<!-- components/UsersList.vue: компонент может быть асинхронным -->
<script setup>
const { data: users } = await useFetch('/api/users')
</script>

<template>
  <ul>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

Работает это потому, что await useFetch в setup делает компонент асинхронным, а Suspense держит #fallback, пока он не «разрешится». Это удобно, когда состояний загрузки много и не хочется дублировать разметку в каждом компоненте. Но у Suspense нет встроенного слота для ошибок (ошибку ловят через onErrorCaptured), и он предъявляет требования к дереву компонентов, поэтому смешивать оба подхода в одном месте не стоит.

Как обрабатываются ошибки

Важное различие двух инструментов: $fetch бросает ошибку, а useFetch/useAsyncData её перехватывают и кладут в error. Значит, try/catch нужен вокруг $fetch, но не вокруг useFetch.

Ошибку можно сделать осмысленной на самом сервере:

// server/api/users/[id].get.ts
export default defineEventHandler((event) => {
  const user = findUserById(getRouterParam(event, 'id'))
  if (!user) {
    throw createError({ statusCode: 404, statusMessage: 'Пользователь не найден' })
  }
  return user
})

На клиенте она окажется в error.value с полями statusCode, message, data. Для глобальной реакции (например, редирект при 401) удобно завести отдельный инстанс $fetch с интерцептором в плагине.

Запомнить: рисуйте три состояния через status; $fetch бросает ошибку, а composables кладут её в error; осмысленные ошибки создаются на сервере через createError.


Блокирует ли запрос навигацию: await, lazy, immediate

По умолчанию Nuxt блокирует навигацию, пока данные не загрузятся: пока идёт запрос, пользователь остаётся на текущей странице, а затем сразу попадает на заполненную. Это хорошо для важных данных и SEO, но для второстепенных блоков превращается в лишнее ожидание. Управляют этим три независимых переключателя — разберём каждый подробно.

await: дождаться данных перед продолжением

await не влияет на серверный HTML — данные туда попадут в любом случае, Nuxt дожидается их сам. Он определяет две другие вещи: продолжит ли код после вызова немедленно и будет ли ждать клиентская навигация.

С awaitБез await
Код после вызоваждёт готовности dataвыполняется сразу, data пока undefined
Клиентская навигацияждёт данные, потом показывает страницупоказывает страницу сразу, данные догружаются
Кто следит за состояниемawait уже гарантировал данныевы сами — через status и error
<script setup>
// с await: следующая строка видит готовые данные
const { data: post } = await useFetch('/api/post')
console.log(post.value.title) // безопасно
</script>
<script setup>
// без await: код идёт дальше, данные появятся позже
const { data: post, status } = useFetch('/api/post')
// post.value здесь ещё undefined — рисуем загрузку по status
</script>

lazy: не блокировать навигацию

lazy: true (или useLazyFetch) откладывает запрос до монтирования компонента. Страница показывается сразу, данные приходят после — это улучшает воспринимаемую скорость. Цена: загрузку и ошибку вы обязаны отрисовать сами.

<script setup>
const { status, data: posts } = useLazyFetch('/api/posts')
</script>

<template>
  <p v-if="status === 'pending'">Загрузка постов...</p>
  <ul v-else>
    <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
  </ul>
</template>

Подвох: await useLazyFetch(...) не имеет смысла. На клиентской навигации await разрешится сразу, не дожидаясь данных. Если нужно ждать — уберите lazy, а не надейтесь на await.

immediate: false: запуск вручную

Запрос не стартует сам, status равен idle, пока вы не вызовете execute(). Так делают, когда запрос должен уйти по действию пользователя.

<script setup>
const { data, execute, status } = await useFetch('/api/report', {
  immediate: false,
})
// status: 'idle' → 'pending' → 'success'
</script>

<template>
  <button :disabled="status === 'pending'" @click="execute()">
    Загрузить отчёт
  </button>
  <pre v-if="data">{{ data }}</pre>
</template>

server: false: только на клиенте

server: false выполняет запрос исключительно в браузере — для данных, которых не должно быть в HTML (аналитика, персонализация). Подвох: при первом рендере данные ещё не загружены, поэтому data будет undefined, даже если вы поставили await.

<script setup>
const { data, status } = await useFetch('/api/analytics', { server: false })
// на первом клиентском рендере data ещё undefined
</script>

Запомнить: await — про то, ждёт ли код и навигация данные; lazy — про отложенный старт без блокировки; immediate: false — про ручной запуск; server: false — про запрос только в браузере. Это четыре разных переключателя.


Отмена запросов и гонки

Представьте строку поиска. Пользователь быстро набирает «ни», «ник», «ника» — улетают три запроса, и ответы могут прийти в обратном порядке. Тогда на экране окажутся данные для «ни», хотя в поле уже «ника». Это гонка, и её нужно отменять.

Хорошая новость: при использовании useFetch/useAsyncData об этом обычно не нужно думать. Смена реактивных параметров отменяет предыдущий запрос: у композабла политика dedupe: 'cancel' по умолчанию. То есть тот же реактивный query из главы про реактивность сам защищает от гонок:

<script setup>
const filters = reactive({ city: 'Москва', type: 'flat' })

// при изменении фильтра предыдущий запрос отменяется
const { data } = await useFetch('/api/listings', { query: filters })
</script>

Чтобы отмена реально работала, хендлер должен пробросить signal в $fetch (мы видели это в главе про useAsyncData). Тогда отменяются и сетевые запросы.

Если запросы императивные ($fetch), отмену делают вручную через AbortController:

let controller

async function search(query) {
  controller?.abort()
  controller = new AbortController()

  try {
    return await $fetch('/api/search', {
      query: { q: query },
      signal: controller.signal,
    })
  } catch (error) {
    if (isAbortError(error)) return // отмена — это не ошибка
    throw error
  }
}

Запомнить: при реактивных параметрах useFetch отменяет предыдущий запрос сам; для ручных запросов используйте AbortController и пробрасывайте signal в хендлер.


Cookies и авторизация при SSR

Это место, которое ломает новичков в самый неподходящий момент — на авторизации. Разберёмся, почему так, и что с этим делать.

Почему на сервере cookies не улетают сами

В браузере запрос на свой домен автоматически несёт все cookies — это и есть механизм аутентификации. При SSR запрос уходит с сервера приложения, а не из браузера. Если бы Nuxt автоматически приклеивал cookies браузера к любому серверному запросу, открылись бы две дыры:

  • SSRF (Server-Side Request Forgery) — если URL запроса хотя бы частично управляется пользователем, атакующий заставит сервер обратиться к внутреннему ресурсу, недоступному снаружи (внутренний API, метаданные облака, localhost).
  • Утечка сессии (authentication misuse) — проксирование cookies на произвольный хост отдало бы чужому серверу сессию пользователя.

Именно поэтому по умолчанию внутренний $fetch cookies не переносит. Это осознанная защита, а не баг. Общие угрозы и линии защиты разобраны в разделах Безопасность фронтенда: обзор, Безопасность Nuxt и Аутентификация и авторизация.

Как прокинуть cookies осознанно

Решение зависит от инструмента:

  • useFetch с относительным URL проксирует cookies и заголовки сам — ничего делать не надо.
  • useAsyncData + $fetch так не умеет: их нужно прокинуть вручную.
<script setup>
// способ 1: useRequestFetch проксирует все безопасные заголовки
const requestFetch = useRequestFetch()
const { data } = await useAsyncData(() => requestFetch('/api/cookies'))

// способ 2: взять только cookie и передать явно
const headers = useRequestHeaders(['cookie'])
const { data: me } = await useAsyncData(() => $fetch('/api/me', { headers }))
</script>

Не проксируйте лишние заголовки

Проксировать нужно только то, что действительно необходимо, и никогда слепо на внешние API. Заголовки, которые опасно пробрасывать: host, accept, content-length, content-md5, content-type, x-forwarded-host, x-forwarded-port, x-forwarded-proto, cf-connecting-ip, cf-ray. Подробнее — в статье HTTP-заголовки безопасности.

Иногда нужно отдать браузеру cookie, которую установил внутренний запрос (например, API логина проставил сессию). Тогда Set-Cookie переносят вручную:

import { appendResponseHeader } from 'h3'
import type { H3Event } from 'h3'

export const fetchWithCookie = async (event: H3Event, url: string) => {
  const res = await $fetch.raw(url)
  for (const cookie of res.headers.getSetCookie()) {
    appendResponseHeader(event, 'set-cookie', cookie)
  }
  return res._data
}

Ключевой вывод для SSR-приложений: токен удобнее держать в cookie, а не в localStorage. Причины:

  • httpOnly-cookie недоступна из JavaScript — XSS не украдёт токен так просто;
  • cookie автоматически уходит с запросами, поэтому защищённые страницы работают и при SSR.

Обратная сторона: раз cookie отправляется автоматически, появляется риск CSRF. Поэтому cookie-based авторизацию дополняют SameSite и CSRF-токеном. Выбор между cookie + CSRF-защитой и Bearer-токеном в памяти (без CSRF, но с риском XSS) — осознанный компромисс; он подробно разобран в статье CSRF: механика атаки и линии защиты.

Запомнить: на сервере cookies не отправляются автоматически из-за рисков SSRF и утечки сессии; useFetch с относительным URL проксирует их сам, $fetch — только через useRequestFetch, и проксировать нужно лишь необходимые заголовки.


Кэширование данных

Кэш — то, что отличает приложение, которое «просто работает», от приложения, которое работает быстро и не дёргает сервер на каждый чих. В Nuxt кэш живёт на двух уровнях: на клиенте (данные, полученные в браузере, переиспользуются между переходами) и на сервере (сами ответы API-роутов кэшируются в Nitro). Разберём оба.

Уровень 1: клиентский кэш по ключу

Всё, что загружено через useFetch/useAsyncData, хранится в памяти приложения по ключу. Это позволяет одному компоненту прочитать данные, загруженные другим, не делая запрос заново. Достают их через useNuxtData:

<script setup>
// данные уже загружены где-то ещё под ключом 'users'
const { data: users } = useNuxtData('users')
</script>

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

getCachedData: переиспользовать вместо перезапроса

По умолчанию при клиентской навигации (например, пользователь вернулся «назад») данные могут запрашиваться снова. Функция getCachedData позволяет отдавать уже загруженное:

const { data } = await useFetch('/api/posts', {
  key: 'posts',
  getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key],
})

Здесь есть осознанный компромисс: данные перестают запрашиваться, но могут устареть. Для редко меняющегося контента (справочники, список стран, документация) — идеально, для быстро меняющихся данных — лучше оставить перезапрос.

Инвалидация: refreshNuxtData и clearNuxtData

Кэш нужно уметь сбрасывать. Есть два инструмента — обновить и очистить, оба работают глобально или по ключу:

await refreshNuxtData('users') // перезапросить данные под ключом
clearNuxtData('users')          // выкинуть их из кэша

Самый частый сценарий — после мутации. Пользователь создал элемент через $fetch, и связанные списки устарели:

async function createPost(values) {
  await $fetch('/api/posts', { method: 'POST', body: values })
  // один вызов обновляет все компоненты, использующие этот ключ
  await refreshNuxtData('posts')
}

Это удобнее, чем вручную вызывать refresh() в каждом компоненте: обновление идёт по ключу, а не по ссылке на конкретный композабл.

Уровень 2: серверный кэш в Nitro

Тяжёлый запрос к базе или внешнему API незачем выполнять на каждый заход пользователя. Nuxt умеет кэшировать ответы прямо на сервере через routeRules:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/api/posts': { swr: 60 },              // отдать старое, обновить в фоне через 60 с
    '/api/countries': { cache: { maxAge: 60 * 60 } }, // держать ответ час
    '/blog/**': { isr: 3600 },              // инкрементальная регенерация страниц
  },
})

Ключевые стратегии:

СтратегияПоведение
cacheКэшировать ответ на maxAge секунд; всё это время отдаётся одна и та же версия
swrStale-while-revalidate: сразу отдать устаревшее, обновить в фоне
isrКэшировать отрендеренную страницу и перестраивать по расписанию
prerenderСгенерировать один раз на этапе сборки (SSG)

Точечно то же самое делается через defineCachedEventHandler — с контролем ключа и времени жизни:

// server/api/posts.get.ts
export default defineCachedEventHandler(
  () => db.posts.findMany(),
  {
    maxAge: 60,          // секунд
    swr: true,           // отдавать старое, пока обновляется новое
    getKey: () => 'posts-all', // ключ кэша (важно для приватных данных)
  },
)

Осторожно с приватными данными

Серверный кэш общий для всех пользователей. Если закэшировать ответ, зависящий от пользователя (его корзину, баланс, профиль), чужие данные утекут. Для таких роутов либо не включайте кэш, либо задавайте корректный ключ через getKey и добавляйте в ответ Cache-Control: private. Подробнее про серверную часть — в статье Серверная часть Nitro.

Запомнить: на клиенте данные живут по ключу (useNuxtData, getCachedData, refreshNuxtData, clearNuxtData), на сервере — в Nitro (routeRules, defineCachedEventHandler); приватные данные кэшировать общим кэшем нельзя.


Что происходит с данными в пути

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

Payload (данные useAsyncData/useState). Здесь используется сериализатор devalue, который сохраняет не только примитивы, но и Date, Map, Set, RegExp. То есть дата, загруженная на сервере, приедет в браузер именно как Date.

Ответы API-роутов (server/api/*), полученные через $fetch/useFetch, сериализуются обычным JSON.stringify. Последствия:

  • Date превращается в строку;
  • Map и Set становятся {};
  • BigInt бросает ошибку.
// server/api/foo.ts
export default defineEventHandler(() => new Date())
<script setup>
// на клиенте data — уже строка, а не Date
const { data } = await useFetch('/api/foo')
</script>

Если это мешает, можно вернуть объект с методом toJSON и описать нужный формат самим.

И ещё: всё, что попадает в payload, оказывается в HTML, доступном пользователю. Не кладите туда токены и секреты — как с ними обращаться в принципе, разобрано в статье Управление секретами во фронтенде.

Запомнить: payload через devalue сохраняет Date/Map/Set, а API-роуты через JSON.stringify их теряют; секретам в payload не место.


Антипаттерны

Короткий список того, что чаще всего делают неправильно:

  • $fetch в <script setup> для начальных данных — двойной запрос. Нужен useFetch/useAsyncData.
  • Ожидание, что два useFetch с одним URL в разных компонентах поделят данные — нужен явный key.
  • watch для изменения URL — URL так не меняется, нужен computed URL или реактивный query.
  • Побочные эффекты (Pinia-экшены, мутации) внутри хендлера useAsyncData — для них есть callOnce.
  • useFetch для POST/PUT/DELETE — для мутаций используйте $fetch.
  • Секреты и токены в payload — они видны пользователю (см. Управление секретами).
  • Проксирование всех заголовков на внешний API — только необходимые.

Подвохи на собеседовании

Вопросы-ловушки, которые проверяют, понимаете ли вы механику, а не заучили API.

1. Почему $fetch в setup делает двойной запрос, а useFetch — нет? $fetch не переносит состояние с сервера на клиент, поэтому при гидратации выполняется снова. useFetch/useAsyncData кладут результат в payload, и клиент читает его оттуда.

2. useFetch — это что под капотом? Сахар: useFetch(url) ≈ useAsyncData(key, () => $fetch(url)). Отсюда одинаковые возвращаемые значения и опции.

3. Поделят ли данные два useFetch с одним URL в разных компонентах? Нет. Автоключ включает место вызова. Нужен одинаковый явный key.

4. watch: [id] изменит URL запроса? Нет. watch только перезапускает запрос. Для динамического URL — computed URL или реактивный query.

5. await useFetch и без await — разница при SSR? В серверном HTML — никакой, Nuxt ждёт данные всегда. Разница в клиентской навигации и в коде после вызова.

6. Почему cookies не доходят до API при SSR? Внутренний $fetch на сервере их не forwarding-ит из соображений безопасности. Прокинуть можно через useFetch (относительный URL) или useRequestFetch.

7. Как отменяется предыдущий запрос при быстрой смене фильтра? Смена реактивных параметров и dedupe: 'cancel' (по умолчанию) отменяют предыдущий запрос, если хендлер пробросил signal в $fetch.

8. Что происходит с Date в payload и в ответе API-роута? В payload (useAsyncData) Date остаётся датой благодаря devalue. В ответе API-роута — превращается в строку из-за JSON.stringify.

9. Обязан ли хендлер useAsyncData что-то возвращать? Да, truthy-значение. Возврат undefined/null может привести к дублированию запроса на клиенте.

10. Можно ли делать мутации через useFetch? Технически можно, но не нужно: useFetch рассчитан на идемпотентную загрузку, мутации — через $fetch.


Ключевые тезисы для интервью

  • Nuxt рендерит страницу на сервере, поэтому данные для первого рендера получают через useFetch/useAsyncData: результат попадает в payload и не запрашивается повторно при гидратации, а $fetch в setup даёт двойной запрос.
  • useFetch — сахар над useAsyncData + $fetch; useAsyncData берут, когда логика сложнее одного GET или нужен свой query-слой.
  • Реактивность даётся реактивным query или computed URL; watch лишь перезапускает запрос и URL не меняет.
  • Ключ определяет дедупликацию и общее состояние: useFetch строит его из URL, опций и места вызова, поэтому для общих данных нужен явный key, а опции с одним ключом должны совпадать.
  • await управляет блокировкой навигации, lazy — отложенным стартом, immediate: false — ручным запуском; это разные переключатели.
  • Отмена защищает от гонок: смена реактивных параметров и dedupe: 'cancel' отменяют предыдущий запрос, если хендлер пробрасывает signal.
  • При SSR cookies нужно проксировать явно; payload и API-роуты сериализуют данные по-разному (сохранение Date/Map/Set против потери).

Заключение

Data fetching в Nuxt — это не про заучивание трёх функций, а про понимание одной идеи: данные, отрендеренные на сервере, должны пережить переход в браузер без повторного запроса. Всё остальное — useFetch, useAsyncData, ключи, payload, отмена и кэш — обслуживает эту идею.

Если держать в голове порядок «проблема → $fetch → useAsyncData → useFetch → реактивность → ключи → состояния → cookies и кэш», детали складываются в систему, а не в список терминов.

Практика: возьмите одну реальную страницу с пагинацией или авторизацией и прогоните её через lazy, server: false и реактивный query, наблюдая за запросами и содержимым payload во вкладке Payload в Nuxt DevTools. Так механику видно, а не только читается.


Полезные ссылки