Dominando o useFetch no Nuxt 3 e 4 cover image

Dominando o useFetch no Nuxt 3 e 4

Se você já construiu um app Nuxt, provavelmente usou $fetch pelo menos uma vez — e se perguntou por que os dados somem ao recarregar a página, duplicam entre componentes ou aparecem vazios no client após o SSR.

É exatamente esse problema que o useFetch resolve.

useFetch é o composable de data fetching do Nuxt. Ele envolve o $fetch com suporte a SSR, deduplicação de requisições, estado compartilhado e hidratação. Quando você entende como ele funciona por baixo dos panos, ele se torna uma das ferramentas mais poderosas do ecossistema Nuxt.

Este guia cobre o modelo mental, padrões práticos e os casos de borda que costumam aparecer só em produção.

O modelo mental: três trabalhos ao mesmo tempo

Toda chamada a useFetch faz três coisas:

  1. Buscar dados — como o $fetch, mas integrado ao ciclo de vida do Nuxt

  2. Compartilhar resultados — a mesma key retorna o mesmo estado reativo entre componentes

  3. Conectar server e client — a saída do SSR é serializada no payload e hidratada no client

Quando uma página renderiza no servidor, o Nuxt executa suas chamadas useFetch, armazena os resultados em nuxtApp.payload.data e embute esse payload no HTML. No client, o Nuxt reutiliza esses dados em vez de disparar requisições duplicadas.

Por isso useFetch('/api/users') em dois componentes irmãos não significa duas chamadas HTTP — desde que compartilhem a mesma key.

useFetch vs $fetch vs useAsyncData

  • $fetch — requisições pontuais em event handlers, server routes ou utilitários

  • useFetch — requisições HTTP em componentes/páginas com SSR + cache

  • useLazyFetch — igual ao useFetch, mas não bloqueia a navegação

  • useAsyncData — qualquer trabalho assíncrono: queries no banco, leitura de arquivos, lógica em múltiplas etapas

useFetch é essencialmente useAsyncData + $fetch conectados:

// Conceitualmente equivalentes:
const { data } = await useFetch('/api/posts')

const { data } = await useAsyncData('posts', () => $fetch('/api/posts'))

Use useAsyncData quando o fetch não for uma requisição HTTP simples. Use useFetch quando for.

Seu primeiro fetch

<script setup lang="ts">
const { data, status, error, refresh } = await useFetch('/api/posts')
</script>

<template>
  <Loader v-if="status === 'pending'" />
  <p v-else-if="error">{{ error.message }}</p>
  <PostList v-else :posts="data" />
</template>

No Nuxt 4, prefira status em vez do booleano pending mais antigo. Ele oferece estados explícitos: 'idle', 'pending', 'success' e 'error'.

Para dados críticos que precisam existir antes da página renderizar, aguarde o fetch no top level. O Nuxt vai esperar durante o SSR.

Para dados não bloqueantes — widgets, sidebars, conteúdo abaixo da dobra — use useLazyFetch:

const { data: stories } = useLazyFetch('https://api.example.com/stories', {
  key: 'featured-stories'
})

A navegação segue imediatamente; o componente renderiza com status === 'pending' até os dados chegarem.

Keys: a opção mais importante que você não está definindo

Por padrão, o Nuxt gera uma key a partir da URL e das opções. Isso funciona até você adicionar query params reativos, rotas dinâmicas ou composables compartilhados.

Sempre defina uma key explícita quando:

  • A URL sozinha não é única o suficiente

  • Você faz fetch dentro de um composable reutilizável

  • Quer comportamento de cache previsível entre navegações

// composables/useHackerNews.ts
export function useHackerNews() {
  return useLazyFetch('https://hn.algolia.com/api/v1/search', {
    query: { tags: 'front_page', hitsPerPage: 10 },
    key: 'hacker-news-front-page',
    transform: (response) => response.hits.map(toStory)
  })
}

Sem key: 'hacker-news-front-page', dois componentes chamando esse composable podem obter entradas de cache não relacionadas — ou perder a deduplicação.

Regra prática: se você encapsula useFetch em um composable, defina uma key.

Queries reativas e refetch automático

Passe refs reativas em query, e o Nuxt refaz o fetch quando elas mudam:

<script setup lang="ts">
const route = useRoute()
const pageNumber = computed(() => route.params.page)

const { data: posts, status } = await useFetch('/api/posts', {
  query: { page: pageNumber }
})
</script>

Quando o usuário navega de /articles/page/1 para /articles/page/2, pageNumber atualiza e o Nuxt busca a próxima página automaticamente. Sem watch manual.

Para outras dependências reativas, use a opção watch:

const category = ref('nuxt')

const { data } = await useFetch('/api/posts', {
  query: { category },
  watch: [category]
})

Transform, pick e default

transform — modele os dados antes de chegar ao template

APIs raramente retornam exatamente o que a UI precisa. Transforme na camada de fetch, não em cada componente:

const { data: stories } = await useLazyFetch(apiUrl, {
  transform: (response: ApiResponse) =>
    response.hits.filter(hit => hit.title).map(toStory)
})

O valor transformado é o que vai para o cache e para a hidratação. Seus componentes ficam limpos.

pick — cacheie só o que você precisa

const { data } = await useFetch('/api/user', {
  pick: ['id', 'name', 'avatar']
})

Útil quando a API retorna objetos grandes, mas a página precisa de poucos campos.

default — evite null checks em todo lugar

const { data: posts } = await useFetch('/api/posts', {
  default: () => []
})

data é sempre um array, mesmo antes da requisição completar.

Bloqueante vs lazy: escolhendo o padrão certo

  • Conteúdo da página (artigo, detalhe de projeto) → await useFetch(...) — bloqueia até estar pronto

  • Config bootstrap global do appawait useFetch(..., { lazy: false }) no app.vue

  • Widgets de sidebar, seções opcionaisuseLazyFetch(...)

  • Ações disparadas pelo usuário$fetch dentro de um event handler

No app.vue, dados bootstrap dos quais todas as páginas dependem devem bloquear o SSR:

<script setup lang="ts">
const { data } = await useApi<Bootstrap>('/bootstrap', { lazy: false })

if (data.value) {
  bootstrap.value = data.value
}
</script>

Todo o resto do app herda esse contexto sem refazer o fetch.

Tratamento de erros que funciona com SSR

Lançar erros dentro de um componente quebra o SSR silenciosamente ou produz renders parciais confusos. Use as utilidades de erro do Nuxt:

const { data: post, error } = await useFetch(`/api/posts/${slug}`)

if (error.value) {
  throw createError({
    statusCode: error.value.statusCode ?? 404,
    statusMessage: 'Post not found'
  })
}

O Nuxt renderiza a página de erro apropriada no server e no client, com o status HTTP correto.

Para falhas mais leves — widgets opcionais — trate erros no template:

<template>
  <aside v-if="error">Não foi possível carregar recomendações.</aside>
  <Recommendations v-else :items="data" />
</template>

Criando um composable customizado com createUseFetch

Se toda página repete o mesmo baseURL, headers de auth e lógica de cache, extraia um composable global:

// composables/useApi.ts
export const useApi = createUseFetch((callerOptions) => ({
  baseURL: useRuntimeConfig().public.apiUrl,
  lazy: true,
  ...callerOptions,
  getCachedData: (key, nuxtApp) => {
    return nuxtApp.payload.data[key] ?? nuxtApp.static?.data?.[key]
  }
}))

Agora cada fetch de página é uma linha:

const { data: projects } = await useApi<Project[]>('/projects/featured')
const { data: post } = await useApi<Post>(`/posts/${slug}`)

Wrappers finos como usePageFetch mantêm a lógica específica de rota em um só lugar:

export function usePageFetch<T>(slug: string) {
  return useApi<Page<T>>(`/pages/${slug}`)
}

Esse padrão escala bem: defaults ficam no useApi, semântica de domínio em composables nomeados, páginas legíveis.

Cache, ISR e getCachedData

O Nuxt 4 mudou a reatividade padrão de deep para shallow por performance. Para a maioria dos dados buscados, isso é suficiente — você substitui o objeto inteiro no refetch. Se mutar propriedades aninhadas diretamente, opte por:

const { data } = await useAsyncData('user', () => $fetch('/api/user'), {
  deep: true
})

Para ISR e geração estática, getCachedData permite reutilizar dados do payload entre navegações em vez de refazer o fetch:

getCachedData: (key, nuxtApp) => {
  return nuxtApp.payload.data[key] ?? nuxtApp.static?.data?.[key]
}

Combinado com route rules como isr: 300, suas páginas servem HTML em cache e revalidam em background — enquanto navegações client-side continuam instantâneas porque o payload já está quente.

Deduplicação e cancelamento de requisições (Nuxt 4.2+)

Quando usuários disparam refetches rápidos — busca enquanto digita, troca de abas — controle o que acontece com requisições em andamento:

const { data } = await useFetch('/api/search', {
  query: { q: searchTerm },
  watch: [searchTerm],
  dedupe: 'cancel'
})

Para controle manual, passe um AbortSignal:

const controller = new AbortController()

const { data, execute } = await useFetch('/api/users', {
  immediate: false,
  signal: controller.signal
})

await execute()
// depois: controller.abort()

Dentro de handlers useAsyncData, o signal está disponível como argumento:

const { data, execute } = await useAsyncData('users',
  ({ signal }) => $fetch('/api/users', { signal }),
  { immediate: false }
)

Armadilhas comuns

1. Usar $fetch no setup sem useFetch

// ❌ Roda no server E no client — fetch duplo, sem estado compartilhado
const posts = await $fetch('/api/posts')

// ✅ Buscado uma vez, hidratado no client
const { data: posts } = await useFetch('/api/posts')

2. Esquecer o await em páginas que precisam de dados no SSR

// ❌ Página renderiza antes dos dados chegarem no server
const { data } = useFetch('/api/posts')

// ✅ Nuxt espera o fetch durante o SSR
const { data } = await useFetch('/api/posts')

3. URLs de API hardcoded

Use useRuntimeConfig() para base URLs, assim o mesmo código funciona em dev, staging e produção:

const config = useRuntimeConfig()
const { data } = await useFetch('/posts', {
  baseURL: config.public.apiUrl
})

Ou centralize em um wrapper createUseFetch.

4. Mutar dados buscados sem deep: true

No Nuxt 4, mutações aninhadas em refs shallow não disparam reatividade. Refaça o fetch ou defina deep: true se realmente precisar de mutação in-place.

5. Keys ausentes em composables

Se duas chamadas geram auto-keys diferentes para o mesmo recurso, você perde a deduplicação. Defina keys explícitas.

Cheat sheet de decisão

Precisa buscar em um componente ou página?
├── É uma requisição HTTP simples?
│   ├── Deve bloquear a renderização?     → await useFetch()
│   └── Pode carregar após a navegação?   → useLazyFetch()
└── Lógica assíncrona customizada (DB, arquivos)? → useAsyncData()

Precisa buscar em um event handler?        → $fetch()

Precisa de defaults compartilhados no app? → createUseFetch()

Precisa refazer fetch quando um ref muda?  → query: { param: myRef }

Precisa cancelar requisições obsoletas?    → dedupe: 'cancel' ou AbortSignal

Juntando tudo

Uma página de listagem paginada costuma combinar vários padrões:

<script setup lang="ts">
const route = useRoute()
const pageNumber = computed(() => route.params.page)

const { data: page, status: pageStatus, error: pageError } = usePageFetch('articles')

const { data: posts, status, error } = await useApi<PaginatedResponse<Post>>('/posts', {
  query: { page: pageNumber }
})

if (pageError.value || error.value) {
  throw createError({
    statusCode: pageError.value?.status ?? error.value?.status ?? 500
  })
}
</script>

<template>
  <Loader v-if="pageStatus === 'pending' || status === 'pending'" />
  <PostList v-else-if="page && posts" :posts="posts" :page="page" />
</template>

Dois composables, paginação reativa, respostas tipadas, tratamento de erro compatível com SSR — e zero gerenciamento manual de cache.

Considerações finais

useFetch não é só um wrapper conveniente em torno do fetch. É o contrato do Nuxt entre sua camada de dados e o pipeline de renderização: o que é buscado no server, o que viaja no payload, o que é compartilhado entre componentes e o que refaz fetch na navegação.

Quem tem dificuldade com data fetching no Nuxt geralmente trata useFetch como um $fetch reativo. Quem domina pensa em keys, payloads e hidratação — e deixa o Nuxt cuidar do resto.