
Nuxt Data
- 297 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
nuxt-data is a version 4.0.0 MIT skill for Nuxt 4 that guides useFetch, useAsyncData, useState, and Pinia patterns for developers building SSR-safe composables and reactive API data layers.
About
nuxt-data is a secondsky/claude-skills package (version 4.0.0, MIT, last verified 2025-12-28) covering Nuxt 4 data management with composables, data fetching, and state patterns. It documents three fetching methods—useFetch for simple API calls, useAsyncData for custom async logic, and $fetch for client-only events—plus useState for SSR-safe shared singletons and Pinia for complex stores. The skill emphasizes Nuxt 4's shallow reactivity default, reactive query parameters, transform and pick options, caching with dedupe, and anti-patterns like using ref instead of useState. A developer reaches for nuxt-data when wiring reactive API calls, debugging data that fails to refresh on param changes, or implementing auth, pagination, or cart state in Nuxt 4.
- nuxt-data
Nuxt Data by the numbers
- 297 all-time installs (skills.sh)
- +11 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,337 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill nuxt-dataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 297 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
How do you fetch API data safely in Nuxt 4?
Use nuxt-data for development tasks
Who is it for?
Frontend developers building Nuxt 4 apps who need production patterns for data fetching, shared state, and hydration-safe composables.
Skip if: Nuxt 3 projects, server-only API route design, or teams not using Vue/Nuxt who should pick framework-agnostic data skills.
When should I use this skill?
The user asks about useFetch, useAsyncData, useState, Pinia in Nuxt 4, or debugging reactive data and hydration issues.
What you get
SSR-safe composables, reactive useFetch/useAsyncData calls, useState singletons, and Pinia store integrations
- SSR-safe composables
- reactive data fetching setup
- Pinia store integration
By the numbers
- Skill version 4.0.0 targeting Nuxt framework-version 4.x
- Documents 3 data fetching methods: useFetch, useAsyncData, and $fetch
- Includes 3 reference guides for composables, data fetching, and Pinia
Files
Nuxt 4 Data Management
Composables, data fetching, and state management patterns for Nuxt 4 applications.
Quick Reference
Data Fetching Methods
| Method | Use Case | SSR | Caching | Reactive |
|---|---|---|---|---|
useFetch | Simple API calls | Yes | Yes | Yes |
useAsyncData | Custom async logic | Yes | Yes | Yes |
$fetch | Client-side only, events | No | No | No |
Composable Naming
| Prefix | Purpose | Example |
|---|---|---|
use | State/logic composable | useAuth, useCart |
fetch | Data fetching only | fetchUsers (rare) |
When to Load References
Load `references/composables.md` when:
- Writing custom composables with complex state
- Debugging state management issues or memory leaks
- Implementing SSR-safe patterns with browser APIs
- Building authentication or complex state composables
- Understanding singleton vs per-call composable patterns
Load `references/data-fetching.md` when:
- Implementing API data fetching with reactive parameters
- Troubleshooting shallow vs deep reactivity issues
- Debugging data not refreshing when params change
- Implementing pagination, infinite scroll, or search
- Understanding transform functions, caching, or error handling
Load `references/pinia-integration.md` when:
- Setting up Pinia for complex state management
- Creating stores with getters and actions
- Integrating Pinia with SSR
- Persisting state across page reloads
Composables
useState - The Foundation
useState creates SSR-safe, shared reactive state that persists across component instances.
// composables/useCounter.ts
export const useCounter = () => {
// Singleton - shared across all components
const count = useState('counter', () => 0)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = 0
return { count, increment, decrement, reset }
}useState vs ref - Critical Distinction
// CORRECT: Shared state (singleton pattern)
export const useAuth = () => {
const user = useState('auth-user', () => null) // Shared!
return { user }
}
// WRONG: Creates new instance every call!
export const useAuth = () => {
const user = ref(null) // Not shared!
return { user }
}Rule: Use useState for shared/global state. Use ref for local component state only.
Complete Authentication Composable
// composables/useAuth.ts
export const useAuth = () => {
const user = useState<User | null>('auth-user', () => null)
const isAuthenticated = computed(() => !!user.value)
const isLoading = useState('auth-loading', () => false)
const login = async (email: string, password: string) => {
isLoading.value = true
try {
const data = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
user.value = data.user
return { success: true }
} catch (error) {
return { success: false, error: error.message }
} finally {
isLoading.value = false
}
}
const logout = async () => {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
navigateTo('/login')
}
const checkSession = async () => {
if (import.meta.server) return // Skip on server
try {
const data = await $fetch('/api/auth/session')
user.value = data.user
} catch {
user.value = null
}
}
return { user, isAuthenticated, isLoading, login, logout, checkSession }
}SSR-Safe Browser APIs
// composables/useLocalStorage.ts
export const useLocalStorage = <T>(key: string, defaultValue: T) => {
const data = useState<T>(key, () => defaultValue)
// Only access localStorage on client
if (import.meta.client) {
const stored = localStorage.getItem(key)
if (stored) {
data.value = JSON.parse(stored)
}
// Watch and persist changes
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
}
return data
}Data Fetching
useFetch - Basic Usage
// Simple GET request
const { data, error, pending, refresh } = await useFetch('/api/users')
// With options
const { data: users } = await useFetch('/api/users', {
method: 'GET',
query: { limit: 10, offset: 0 },
headers: { 'X-Custom-Header': 'value' }
})Reactive Parameters
<script setup lang="ts">
const page = ref(1)
const search = ref('')
// Auto-refetches when page or search changes
const { data: users, pending } = await useFetch('/api/users', {
query: {
page,
search,
limit: 10
}
})
// Or with computed
const query = computed(() => ({
page: page.value,
search: search.value,
limit: 10
}))
const { data } = await useFetch('/api/users', { query })
</script>Transform Data
const { data: userNames } = await useFetch('/api/users', {
transform: (users) => users.map(u => u.name)
})
// data.value is now string[] instead of User[]Pick Specific Fields
const { data } = await useFetch('/api/user', {
pick: ['id', 'name', 'email'] // Only these fields in payload
})useAsyncData - Custom Logic
// Multiple parallel requests
const { data } = await useAsyncData('dashboard', async () => {
const [users, posts, stats] = await Promise.all([
$fetch('/api/users'),
$fetch('/api/posts'),
$fetch('/api/stats')
])
return { users, posts, stats }
})
// Access: data.value.users, data.value.posts, data.value.statsError Handling
const { data, error, status } = await useFetch('/api/users')
// Check error
if (error.value) {
console.error('Error:', error.value.message)
console.error('Status:', error.value.statusCode)
}
// Status values: 'idle' | 'pending' | 'success' | 'error'
if (status.value === 'error') {
showError(error.value)
}Manual Refresh
const { data, refresh, execute } = await useFetch('/api/users', {
immediate: false // Don't fetch on mount
})
// Fetch manually
await execute()
// Refresh (re-fetch)
await refresh()
// Refresh with new params
await refresh({ dedupe: true })Shallow vs Deep Reactivity (v4 Change)
// Nuxt 4 default: Shallow reactivity
const { data } = await useFetch('/api/user')
data.value.name = 'New Name' // Won't trigger reactivity!
// Enable deep reactivity for mutations
const { data } = await useFetch('/api/user', {
deep: true
})
data.value.name = 'New Name' // Now works!
// Or refresh instead of mutating
const { data, refresh } = await useFetch('/api/user')
await $fetch('/api/user', { method: 'PATCH', body: { name: 'New Name' } })
await refresh() // Re-fetch updated dataCaching and Deduplication
const { data } = await useFetch('/api/users', {
key: 'users-list', // Custom cache key
dedupe: 'cancel', // Cancel duplicate requests
getCachedData: (key, nuxtApp) => {
// Return cached data if valid
return nuxtApp.payload.data[key]
}
})Lazy Loading Data
// useLazyFetch - Navigation happens immediately, data loads in background
const { data, pending } = useLazyFetch('/api/users')
// useLazyAsyncData
const { data, pending } = useLazyAsyncData('users', () => $fetch('/api/users'))$fetch - Client-Side Only
// In event handlers (not during SSR)
const submitForm = async () => {
const result = await $fetch('/api/submit', {
method: 'POST',
body: formData.value
})
}
// In server routes
export default defineEventHandler(async (event) => {
const externalData = await $fetch('https://api.example.com/data')
return externalData
})State Management
useState Patterns
// Simple counter
const count = useState('count', () => 0)
// Complex object
const settings = useState('settings', () => ({
theme: 'light',
notifications: true,
language: 'en'
}))
// Typed state
interface User {
id: string
name: string
email: string
}
const user = useState<User | null>('user', () => null)Shared Cart Example
// composables/useCart.ts
interface CartItem {
id: string
name: string
price: number
quantity: number
}
export const useCart = () => {
const items = useState<CartItem[]>('cart-items', () => [])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const addItem = (product: Omit<CartItem, 'quantity'>) => {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
const removeItem = (id: string) => {
items.value = items.value.filter(i => i.id !== id)
}
const updateQuantity = (id: string, quantity: number) => {
const item = items.value.find(i => i.id === id)
if (item) {
item.quantity = Math.max(0, quantity)
if (item.quantity === 0) removeItem(id)
}
}
const clearCart = () => {
items.value = []
}
return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart }
}Pinia Integration
bun add pinia @pinia/nuxt// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt']
})
// stores/auth.ts
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: null as string | null
}),
getters: {
isAuthenticated: (state) => !!state.user,
userName: (state) => state.user?.name ?? 'Guest'
},
actions: {
async login(email: string, password: string) {
const { user, token } = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
this.user = user
this.token = token
},
logout() {
this.user = null
this.token = null
}
}
})
// Usage in components
const authStore = useAuthStore()
await authStore.login('user@example.com', 'password')
console.log(authStore.userName)Common Anti-Patterns
Using ref Instead of useState
// WRONG - Creates new instance every time!
export const useAuth = () => {
const user = ref(null) // Not shared
return { user }
}
// CORRECT
export const useAuth = () => {
const user = useState('auth-user', () => null)
return { user }
}Missing Error Handling
// WRONG
const { data } = await useFetch('/api/users')
console.log(data.value.length) // Crashes if error!
// CORRECT
const { data, error } = await useFetch('/api/users')
if (error.value) {
showToast({ type: 'error', message: error.value.message })
return
}
console.log(data.value.length)Non-Deterministic Transform
// WRONG - Causes hydration mismatch!
const { data } = await useFetch('/api/users', {
transform: (users) => users.sort(() => Math.random() - 0.5)
})
// CORRECT
const { data } = await useFetch('/api/users', {
transform: (users) => users.sort((a, b) => a.name.localeCompare(b.name))
})Mutating Shallow Refs
// WRONG - v4 uses shallow refs by default
const { data } = await useFetch('/api/user')
data.value.name = 'New Name' // Won't trigger reactivity!
// CORRECT - Option 1: Enable deep
const { data } = await useFetch('/api/user', { deep: true })
data.value.name = 'New Name'
// CORRECT - Option 2: Replace entire value
data.value = { ...data.value, name: 'New Name' }
// CORRECT - Option 3: Refresh after mutation
await $fetch('/api/user', { method: 'PATCH', body: { name: 'New Name' } })
await refresh()Troubleshooting
Data Not Refreshing When Params Change:
- Ensure params are reactive:
{ query: { page } }wherepage = ref(1) - Check you're using the ref itself, not
.value
Hydration Mismatch with useState:
- Ensure key is unique:
useState('unique-key', () => value) - Avoid
Math.random()orDate.now()in initial values
State Lost on Navigation:
- Use
useStateinstead ofreffor persistent state - Check you're using the same key across components
Infinite Refetch Loop:
- Check for reactive dependencies in transform function
- Use
watchwith{ immediate: false }for side effects
Related Skills
- nuxt-core: Project setup, routing, configuration
- nuxt-server: Server routes, API patterns
- nuxt-production: Performance, testing, deployment
---
Version: 4.0.0 | Last Updated: 2025-12-28 | License: MIT
Composables - Advanced Patterns
Comprehensive guide to creating and using composables in Nuxt 4.
Table of Contents
- Naming Conventions
- useState vs ref
- SSR-Safe Patterns
- Error Handling
- TypeScript Patterns
- Testing Composables
- Advanced Patterns
Naming Conventions
Always Use use Prefix
// ✅ Good
export const useAuth = () => { /* ... */ }
export const useCart = () => { /* ... */ }
export const useProductFilters = () => { /* ... */ }
// ❌ Bad
export const auth = () => { /* ... */ }
export const getCart = () => { /* ... */ }
export const productFilters = () => { /* ... */ }Why? The use prefix is a universal convention that immediately identifies a function as a composable.
Be Specific and Descriptive
// ✅ Good
export const useUserProfile = () => { /* ... */ }
export const useShoppingCart = () => { /* ... */ }
export const useProductSearch = () => { /* ... */ }
// ❌ Bad (too vague)
export const useProfile = () => { /* ... */ }
export const useCart = () => { /* ... */ }
export const useSearch = () => { /* ... */ }Namespace for Large Apps
// For large apps, namespace your composables
export const useAuthUser = () => { /* ... */ }
export const useAuthSession = () => { /* ... */ }
export const useAuthPermissions = () => { /* ... */ }
export const useCartItems = () => { /* ... */ }
export const useCartTotal = () => { /* ... */ }
export const useCartCheckout = () => { /* ... */ }useState vs ref
The Golden Rule
Use `useState` for shared state that survives component unmount. Use `ref` for local component state.
useState: Shared Global State
// composables/useCounter.ts
export const useCounter = () => {
// Survives component unmount, shared across all components
const count = useState('counter', () => 0)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = 0
return {
count: readonly(count), // Expose as readonly
increment,
decrement,
reset
}
}
// Component A
const { count, increment } = useCounter()
increment() // count = 1
// Component B (different component, same state!)
const { count } = useCounter()
console.log(count.value) // 1 (shared state!)ref: Local Component State
// composables/useLocalCounter.ts
export const useLocalCounter = () => {
// New instance for each component
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
// Component A
const { count, increment } = useLocalCounter()
increment() // count = 1
// Component B (different instance!)
const { count } = useLocalCounter()
console.log(count.value) // 0 (new instance!)When to Use Each
| Use Case | useState | ref |
|---|---|---|
| User authentication state | ✅ | ❌ |
| Shopping cart | ✅ | ❌ |
| UI theme (dark/light) | ✅ | ❌ |
| Global notifications | ✅ | ❌ |
| Form input value | ❌ | ✅ |
| Component open/closed state | ❌ | ✅ |
| Local loading state | ❌ | ✅ |
| Temporary UI state | ❌ | ✅ |
SSR-Safe Patterns
Browser API Guards
// ✅ Pattern 1: Check environment in useState initializer
export const useWindowSize = () => {
const width = useState('window-width', () => {
if (import.meta.client) {
return window.innerWidth
}
return 0
})
const height = useState('window-height', () => {
if (import.meta.client) {
return window.innerHeight
}
return 0
})
return { width, height }
}
// ✅ Pattern 2: Use onMounted
export const useWindowSize = () => {
const width = ref(0)
const height = ref(0)
const update = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width, height }
}LocalStorage/SessionStorage
export const useLocalStorage = <T>(key: string, defaultValue: T) => {
const value = useState<T>(key, () => {
if (import.meta.client) {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : defaultValue
}
return defaultValue
})
const setValue = (newValue: T) => {
value.value = newValue
if (import.meta.client) {
localStorage.setItem(key, JSON.stringify(newValue))
}
}
const removeValue = () => {
value.value = defaultValue
if (import.meta.client) {
localStorage.removeItem(key)
}
}
return {
value: readonly(value),
setValue,
removeValue
}
}
// Usage
const { value: theme, setValue: setTheme } = useLocalStorage('theme', 'light')Document/Window Events
export const useKeyPress = (targetKey: string) => {
const isPressed = ref(false)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === targetKey) {
isPressed.value = true
}
}
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === targetKey) {
isPressed.value = false
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
})
return isPressed
}
// Usage
const escapePressed = useKeyPress('Escape')Error Handling
Expose Error State
export const useApi = <T>(url: string) => {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
const execute = async () => {
isLoading.value = true
error.value = null
try {
const response = await $fetch<T>(url)
data.value = response
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
throw err // Re-throw for caller to handle
} finally {
isLoading.value = false
}
}
return {
data: readonly(data),
error: readonly(error),
isLoading: readonly(isLoading),
execute
}
}
// Usage
const api = useApi<User[]>('/api/users')
try {
await api.execute()
} catch (err) {
// Handle error
showToast({ type: 'error', message: api.error.value?.message })
}Try-Catch Patterns
export const useAuth = () => {
const state = useState('auth', () => ({
user: null as User | null,
error: null as string | null,
isLoading: false
}))
const login = async (email: string, password: string) => {
state.value.isLoading = true
state.value.error = null
try {
const { data, error } = await useFetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
if (error.value) {
throw new Error(error.value.message)
}
state.value.user = data.value.user
await navigateTo('/dashboard')
} catch (err) {
state.value.error = err instanceof Error ? err.message : 'Login failed'
throw err
} finally {
state.value.isLoading = false
}
}
return {
user: computed(() => state.value.user),
error: computed(() => state.value.error),
isLoading: computed(() => state.value.isLoading),
login
}
}TypeScript Patterns
Full Type Safety
interface User {
id: string
email: string
name: string
role: 'admin' | 'user'
}
interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
}
export const useAuth = () => {
const state = useState<AuthState>('auth', () => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null
}))
const login = async (email: string, password: string): Promise<void> => {
// Implementation
}
const logout = async (): Promise<void> => {
// Implementation
}
return {
// Computed for type safety
user: computed(() => state.value.user),
isAuthenticated: computed(() => state.value.isAuthenticated),
isLoading: computed(() => state.value.isLoading),
error: computed(() => state.value.error),
// Methods
login,
logout
}
}
// Usage is fully typed!
const { user, login } = useAuth()
user.value?.email // ✅ TypeScript knows this might be nullGeneric Composables
export const useResource = <T>(resourceName: string) => {
const items = useState<T[]>(`${resourceName}-items`, () => [])
const isLoading = ref(false)
const error = ref<Error | null>(null)
const fetchAll = async () => {
isLoading.value = true
error.value = null
try {
const { data } = await useFetch<T[]>(`/api/${resourceName}`)
items.value = data.value || []
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
} finally {
isLoading.value = false
}
}
const create = async (item: Partial<T>) => {
const { data } = await useFetch<T>(`/api/${resourceName}`, {
method: 'POST',
body: item
})
if (data.value) {
items.value.push(data.value)
}
return data.value
}
return {
items: readonly(items),
isLoading: readonly(isLoading),
error: readonly(error),
fetchAll,
create
}
}
// Usage
interface Product {
id: string
name: string
price: number
}
const products = useResource<Product>('products')
await products.fetchAll()Testing Composables
Basic Test
// composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('starts at 0', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('increments', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
increment()
expect(count.value).toBe(2)
})
it('decrements', () => {
const { count, increment, decrement } = useCounter()
increment()
increment()
increment()
expect(count.value).toBe(3)
decrement()
expect(count.value).toBe(2)
})
it('resets', () => {
const { count, increment, reset } = useCounter()
increment()
increment()
increment()
reset()
expect(count.value).toBe(0)
})
})Testing with Async Operations
// composables/useApi.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useApi } from './useApi'
describe('useApi', () => {
it('fetches data successfully', async () => {
// Mock $fetch
global.$fetch = vi.fn().mockResolvedValue([
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' }
])
const api = useApi('/api/users')
await api.execute()
expect(api.data.value).toHaveLength(2)
expect(api.error.value).toBeNull()
expect(api.isLoading.value).toBe(false)
})
it('handles errors', async () => {
global.$fetch = vi.fn().mockRejectedValue(new Error('Network error'))
const api = useApi('/api/users')
try {
await api.execute()
} catch (err) {
expect(api.error.value?.message).toBe('Network error')
expect(api.data.value).toBeNull()
}
})
})Advanced Patterns
Polling
export const usePolling = <T>(
fetcher: () => Promise<T>,
interval: number = 5000
) => {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isPolling = ref(false)
let intervalId: NodeJS.Timeout | null = null
const poll = async () => {
try {
data.value = await fetcher()
error.value = null
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
}
}
const startPolling = async () => {
if (isPolling.value) return
isPolling.value = true
// Initial fetch
await poll()
// Start interval
intervalId = setInterval(poll, interval)
}
const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
isPolling.value = false
}
// Cleanup on unmount
onUnmounted(() => {
stopPolling()
})
return {
data: readonly(data),
error: readonly(error),
isPolling: readonly(isPolling),
startPolling,
stopPolling
}
}
// Usage
const { data, startPolling, stopPolling } = usePolling(
() => $fetch('/api/stats'),
10000 // Poll every 10 seconds
)
onMounted(() => startPolling())Debouncing
export const useDebounce = <T>(value: Ref<T>, delay: number = 500) => {
const debouncedValue = ref<T>(value.value)
let timeoutId: NodeJS.Timeout | null = null
watch(value, (newValue) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
onUnmounted(() => {
if (timeoutId) {
clearTimeout(timeoutId)
}
})
return debouncedValue
}
// Usage
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 300)
// Fetch only after user stops typing for 300ms
const { data: results } = await useFetch('/api/search', {
query: { q: debouncedQuery }
})Throttling
export const useThrottle = <T>(value: Ref<T>, limit: number = 1000) => {
const throttledValue = ref<T>(value.value)
const lastRun = ref(0)
watch(value, (newValue) => {
const now = Date.now()
if (now - lastRun.value >= limit) {
throttledValue.value = newValue
lastRun.value = now
}
})
return throttledValue
}
// Usage
const scrollPosition = ref(0)
const throttledScroll = useThrottle(scrollPosition, 200)
onMounted(() => {
window.addEventListener('scroll', () => {
scrollPosition.value = window.scrollY
})
})Async State Management
export const useAsyncState = <T>(
promise: Promise<T>,
defaultValue: T
) => {
const state = ref<T>(defaultValue)
const isReady = ref(false)
const isLoading = ref(true)
const error = ref<Error | null>(null)
promise
.then((data) => {
state.value = data
isReady.value = true
})
.catch((err) => {
error.value = err instanceof Error ? err : new Error(String(err))
})
.finally(() => {
isLoading.value = false
})
return {
state: readonly(state),
isReady: readonly(isReady),
isLoading: readonly(isLoading),
error: readonly(error)
}
}
// Usage
const { state: user, isLoading } = useAsyncState(
$fetch('/api/user'),
null
)Composable Composition
// Combine multiple composables
export const useUserProfile = () => {
const { user } = useAuth() // Get auth user
const { data: profile, refresh } = useFetch(() =>
user.value ? `/api/users/${user.value.id}/profile` : null
)
const { value: theme, setValue: setTheme } = useLocalStorage('theme', 'light')
const updateProfile = async (updates: Partial<Profile>) => {
await $fetch(`/api/users/${user.value.id}/profile`, {
method: 'PATCH',
body: updates
})
await refresh()
}
return {
user: readonly(user),
profile: readonly(profile),
theme,
setTheme,
updateProfile
}
}Best Practices Summary
1. Always use `use` prefix for composable names 2. Use `useState` for shared state, ref for local state 3. Guard browser APIs with import.meta.client or onMounted 4. Expose error state explicitly 5. Use TypeScript for type safety 6. Return readonly refs to prevent external mutations 7. Clean up side effects in onUnmounted 8. Test composables in isolation 9. Keep composables focused - one responsibility 10. Document complex composables with JSDoc
Common Pitfalls
❌ Using ref for shared state ❌ Missing SSR guards ❌ Not cleaning up event listeners ❌ Mutating readonly refs ❌ Missing error handling ❌ Not using TypeScript ❌ Forgetting to test ❌ Making composables too complex
---
Last Updated: 2025-11-09
Data Fetching - Complete Guide
Comprehensive guide to data fetching in Nuxt 4 with useFetch, useAsyncData, and $fetch.
Table of Contents
- Method Comparison
- useFetch Deep Dive
- useAsyncData Deep Dive
- $fetch Patterns
- Nuxt v4 Changes
- Error Handling
- Caching Strategies
- Advanced Patterns
Method Comparison
| Feature | useFetch | useAsyncData | $fetch |
|---|---|---|---|
| SSR Support | ✅ Yes | ✅ Yes | ❌ Client only |
| Caching | ✅ Automatic | ✅ Automatic | ❌ No |
| Reactivity | ✅ Reactive | ✅ Reactive | ❌ Not reactive |
| Auto-refresh | ✅ Yes | ✅ Yes | ❌ No |
| Use Case | Simple API calls | Custom logic | Client-side calls |
| Key Required | ❌ Optional | ✅ Required | N/A |
When to Use Each
useFetch:
- Simple GET/POST/PUT/DELETE to APIs
- When you want automatic reactivity
- When caching is important
- SSR is needed
useAsyncData:
- Multiple API calls in parallel
- Custom async logic
- Complex data transformations
- Conditional data fetching
$fetch:
- Client-side only operations
- One-off requests
- Inside event handlers
- No caching needed
useFetch Deep Dive
Basic Usage
// Simple GET request
const { data, error, pending, refresh, status } = await useFetch('/api/users')
// POST request
const { data } = await useFetch('/api/users', {
method: 'POST',
body: { name: 'John', email: 'john@example.com' }
})
// With TypeScript
interface User {
id: string
name: string
email: string
}
const { data } = await useFetch<User[]>('/api/users')
// data is Ref<User[] | null>Reactive Parameters
Key Feature in Nuxt v4: Parameters are reactive by default!
// Query params
const page = ref(1)
const limit = ref(10)
const { data } = await useFetch('/api/users', {
query: {
page, // ✅ Auto-refetch when page changes
limit // ✅ Auto-refetch when limit changes
}
})
// Change page → auto-refetch
page.value = 2 // Triggers new fetch
// URL params
const userId = ref('123')
const { data } = await useFetch(() => `/api/users/${userId.value}`)
// ✅ Auto-refetch when userId changes
userId.value = '456' // Triggers new fetchOptions
const { data } = await useFetch('/api/users', {
// HTTP method
method: 'POST',
// Query parameters
query: { page: 1, limit: 10 },
// Request body
body: { name: 'John' },
// Headers
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json'
},
// Base URL (defaults to nuxt app baseURL)
baseURL: 'https://api.example.com',
// Transform response (must be deterministic!)
transform: (data) => data.map(u => ({ id: u.id, name: u.name })),
// Pick specific fields
pick: ['id', 'name', 'email'],
// Watch for changes (reactive)
watch: [page, limit],
// Immediate fetch (default: true)
immediate: true,
// Lazy (don't block navigation)
lazy: false,
// Server-only
server: true,
// Client-only
client: true,
// Deep reactivity (default: false in v4)
deep: false,
// Default value
default: () => [],
// Retry on error
retry: 3,
retryDelay: 500,
// Timeout
timeout: 10000,
// Credentials
credentials: 'include',
// Cache (uses key for caching)
key: 'users-list',
// Dedupe (prevent duplicate requests)
dedupe: 'cancel' // or 'defer'
})Return Values
const {
data, // Ref<T | null> - Response data
error, // Ref<Error | null> - Error object
pending, // Ref<boolean> - Loading state
refresh, // () => Promise<void> - Manual refresh
execute, // () => Promise<void> - Execute (for lazy fetch)
status, // Ref<'idle' | 'pending' | 'success' | 'error'> - Request status
clear // () => void - Clear data and error
} = await useFetch('/api/users')Transform Function
Important: Must be deterministic (same input = same output)!
// ✅ Good - deterministic
const { data } = await useFetch('/api/users', {
transform: (users) => users.map(u => ({
id: u.id,
name: u.name.toUpperCase(),
initials: u.name.split(' ').map(n => n[0]).join('')
}))
})
// ❌ Bad - non-deterministic
const { data } = await useFetch('/api/users', {
transform: (users) => users.sort(() => Math.random() - 0.5)
})
// ❌ Bad - side effects
const { data } = await useFetch('/api/users', {
transform: (users) => {
console.log('Users loaded') // Side effect!
return users
}
})Lazy Fetching
// Don't block navigation
const { data, pending, execute } = await useFetch('/api/heavy-data', {
lazy: true
})
// Execute manually when needed
const loadData = async () => {
await execute()
}Manual Refresh
const { data, refresh } = await useFetch('/api/users')
// Refresh data manually
const reloadUsers = async () => {
await refresh()
}
// In template
<button @click="refresh">Reload</button>useAsyncData Deep Dive
Basic Usage
// Key is required!
const { data, error, pending } = await useAsyncData(
'users', // Unique key
() => $fetch('/api/users')
)
// With TypeScript
interface User {
id: string
name: string
}
const { data } = await useAsyncData<User[]>(
'users',
() => $fetch('/api/users')
)Multiple API Calls
const { data } = await useAsyncData('dashboard', async () => {
const [users, posts, stats] = await Promise.all([
$fetch('/api/users'),
$fetch('/api/posts'),
$fetch('/api/stats')
])
return { users, posts, stats }
})
// Access data
data.value?.users
data.value?.posts
data.value?.statsReactive Keys (Singleton Pattern)
Nuxt v4 Feature: Same key shares the same data!
// Component A
const { data } = await useAsyncData(
'app-config',
() => $fetch('/api/config')
)
// Component B (gets same data!)
const { data: sameData } = await useAsyncData(
'app-config',
() => $fetch('/api/config')
)
// data and sameData point to the same ref!Computed Keys
const userId = ref('123')
const { data } = await useAsyncData(
() => `user-${userId.value}`, // Reactive key
() => $fetch(`/api/users/${userId.value}`)
)
// Change userId → new key → new fetch
userId.value = '456' // Triggers new fetch with new keyOptions
const { data } = await useAsyncData(
'users',
() => $fetch('/api/users'),
{
// Watch reactive dependencies
watch: [page, limit],
// Transform result
transform: (data) => data.map(u => ({ id: u.id, name: u.name })),
// Pick fields
pick: ['id', 'name'],
// Server-only
server: true,
// Lazy
lazy: false,
// Immediate
immediate: true,
// Default value
default: () => [],
// Deep reactivity
deep: false,
// Dedupe
dedupe: 'cancel'
}
)Custom Logic
const { data, error } = await useAsyncData('user-posts', async () => {
// Get user
const user = await $fetch(`/api/users/${userId.value}`)
// Get user's posts
const posts = await $fetch(`/api/posts?userId=${user.id}`)
// Get comments for each post
const postsWithComments = await Promise.all(
posts.map(async (post) => {
const comments = await $fetch(`/api/comments?postId=${post.id}`)
return { ...post, comments }
})
)
return {
user,
posts: postsWithComments
}
})$fetch Patterns
Client-Side Only
// Event handler
const handleSubmit = async () => {
try {
const response = await $fetch('/api/users', {
method: 'POST',
body: { name: 'John' }
})
console.log('User created:', response)
} catch (error) {
console.error('Failed:', error)
}
}
// One-off request
const deleteUser = async (id: string) => {
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
}With Options
const response = await $fetch('/api/users', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token.value}`
},
body: { name: 'John', email: 'john@example.com' },
query: { include: 'profile' },
baseURL: 'https://api.example.com',
timeout: 10000,
retry: 3,
retryDelay: 500
})Error Handling
try {
const user = await $fetch(`/api/users/${id}`)
} catch (error) {
if (error.statusCode === 404) {
console.error('User not found')
} else if (error.statusCode === 401) {
console.error('Unauthorized')
} else {
console.error('Unknown error:', error)
}
}Nuxt v4 Changes
Shallow Reactivity (Default)
Breaking Change: Default changed from deep: true to deep: false
// Nuxt v4 - Shallow (default)
const { data } = await useFetch('/api/users')
// ✅ This works
data.value = newUsers
// ❌ This doesn't trigger reactivity
data.value[0].name = 'New Name'
// Need deep reactivity? Enable it:
const { data } = await useFetch('/api/users', {
deep: true
})
// ✅ Now this works
data.value[0].name = 'New Name'Default Values Changed
Breaking Change: Default changed from null to undefined
// Nuxt v3
const { data } = await useFetch('/api/users')
data.value // null initially
// Nuxt v4
const { data } = await useFetch('/api/users')
data.value // undefined initially
// Provide default value
const { data } = await useFetch('/api/users', {
default: () => []
})
data.value // [] initiallyReactive Keys
New feature - computed/ref keys trigger refetch:
const userId = ref('123')
// Nuxt v4 - Auto-refetch
const { data } = await useAsyncData(
() => `user-${userId.value}`,
() => $fetch(`/api/users/${userId.value}`)
)
userId.value = '456' // Auto-refetch!Error Handling
Basic Error Handling
const { data, error, status } = await useFetch('/api/users')
if (error.value) {
console.error('Error:', error.value.message)
console.error('Status code:', error.value.statusCode)
console.error('Status text:', error.value.statusMessage)
}
// Check status
if (status.value === 'error') {
// Handle error
}Structured Error Handling
const { data, error } = await useFetch('/api/users')
if (error.value) {
const { statusCode, statusMessage, message } = error.value
switch (statusCode) {
case 400:
showToast({ type: 'error', message: 'Invalid request' })
break
case 401:
await navigateTo('/login')
break
case 403:
showToast({ type: 'error', message: 'Access denied' })
break
case 404:
showToast({ type: 'error', message: 'Not found' })
break
case 500:
showToast({ type: 'error', message: 'Server error' })
break
default:
showToast({ type: 'error', message: message || 'Unknown error' })
}
}Retry on Error
const { data } = await useFetch('/api/users', {
retry: 3, // Retry up to 3 times
retryDelay: 500, // Wait 500ms between retries
onRequestError({ error }) {
console.error('Request error:', error)
},
onResponseError({ response }) {
console.error('Response error:', response.status)
}
})Caching Strategies
Automatic Caching
useFetch and useAsyncData cache by key:
// First call - fetches from server
const { data } = await useFetch('/api/users', {
key: 'users-list'
})
// Second call - uses cache
const { data: cachedData } = await useFetch('/api/users', {
key: 'users-list' // Same key = same cache
})Manual Cache Control
const { data, refresh, clear } = await useFetch('/api/users')
// Refresh from server
await refresh()
// Clear cache
clear()Cache Invalidation
const { data, refresh } = await useFetch('/api/users', {
key: 'users-list'
})
// After creating new user
const createUser = async (user: User) => {
await $fetch('/api/users', {
method: 'POST',
body: user
})
// Invalidate cache
await refresh()
}Server-Side Caching with Route Rules
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/api/users': {
swr: 3600 // Cache for 1 hour
}
}
})Advanced Patterns
Infinite Scroll
const page = ref(1)
const allUsers = ref<User[]>([])
const hasMore = ref(true)
const { data, pending, refresh } = await useFetch(() => `/api/users?page=${page.value}`)
watch(data, (newData) => {
if (newData) {
allUsers.value.push(...newData.items)
hasMore.value = newData.hasNextPage
}
})
const loadMore = async () => {
if (hasMore.value && !pending.value) {
page.value++
await refresh()
}
}Pagination
const page = ref(1)
const limit = ref(10)
const { data: paginatedUsers } = await useFetch('/api/users', {
query: { page, limit }
})
// Navigation
const nextPage = () => page.value++
const prevPage = () => page.value = Math.max(1, page.value - 1)
const goToPage = (p: number) => page.value = pOptimistic Updates
const { data: users, refresh } = await useFetch<User[]>('/api/users')
const deleteUser = async (id: string) => {
// Optimistic update
const originalUsers = users.value
users.value = users.value?.filter(u => u.id !== id) || []
try {
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
} catch (error) {
// Rollback on error
users.value = originalUsers
showToast({ type: 'error', message: 'Failed to delete user' })
}
}Dependent Queries
const { data: user } = await useFetch(() => `/api/users/${userId.value}`)
// Only fetch posts when user is loaded
const { data: posts } = await useFetch(
() => user.value ? `/api/posts?userId=${user.value.id}` : null
)Polling
const { data, refresh } = await useFetch('/api/stats')
// Poll every 5 seconds
const intervalId = setInterval(refresh, 5000)
// Cleanup
onUnmounted(() => {
clearInterval(intervalId)
})Abort Requests (v4.2)
const controller = ref<AbortController>()
const { data } = await useAsyncData(
'users',
() => $fetch('/api/users', { signal: controller.value?.signal })
)
// Abort request
const abortRequest = () => {
controller.value?.abort()
controller.value = new AbortController()
}Parallel Requests
const { data: dashboard } = await useAsyncData('dashboard', async () => {
const [users, posts, comments, stats] = await Promise.all([
$fetch('/api/users'),
$fetch('/api/posts'),
$fetch('/api/comments'),
$fetch('/api/stats')
])
return { users, posts, comments, stats }
})Sequential Requests (When Order Matters)
const { data } = await useAsyncData('user-flow', async () => {
// Step 1: Get user
const user = await $fetch(`/api/users/${userId.value}`)
// Step 2: Get user's team (depends on user)
const team = await $fetch(`/api/teams/${user.teamId}`)
// Step 3: Get team members (depends on team)
const members = await $fetch(`/api/teams/${team.id}/members`)
return { user, team, members }
})Best Practices
1. Use useFetch for simple API calls, useAsyncData for complex logic 2. Always handle errors explicitly 3. Provide TypeScript types for better DX 4. Use reactive parameters for auto-refetch 5. Enable deep reactivity only when needed (performance) 6. Use unique keys for caching and deduplication 7. Provide default values to avoid undefined/null checks 8. Transform on server when possible (better performance) 9. Cache aggressively with route rules 10. Clean up subscriptions in onUnmounted
Common Pitfalls
❌ Non-deterministic transforms ❌ Missing error handling ❌ Not using TypeScript ❌ Forgetting to watch reactive deps ❌ Using $fetch in SSR (won't work!) ❌ Not providing default values ❌ Enabling deep when not needed (performance hit) ❌ Missing cleanup (memory leaks)
---
Last Updated: 2025-12-28
// Authentication composable example
interface User {
id: string
email: string
name: string
role: 'admin' | 'user'
}
interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
sessionError: string | null // Track session check failures
lastSessionCheck: number | null // Track when last check occurred
}
export const useAuth = () => {
// Shared state across all components
const state = useState<AuthState>('auth', () => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
sessionError: null,
lastSessionCheck: null
}))
// Login
const login = async (email: string, password: string) => {
state.value.isLoading = true
state.value.error = null
try {
const { data, error } = await useFetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
if (error.value) {
throw new Error(error.value.message || 'Login failed')
}
state.value.user = data.value?.user || null
state.value.isAuthenticated = true
// Navigate to dashboard
await navigateTo('/dashboard')
} catch (err) {
state.value.error = err instanceof Error ? err.message : 'Login failed'
throw err
} finally {
state.value.isLoading = false
}
}
// Logout
const logout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
} catch (err) {
// Log API failure but still proceed with local logout
console.error('Logout API failed:', err)
} finally {
state.value.user = null
state.value.isAuthenticated = false
await navigateTo('/login')
}
}
// Check session (call on app mount)
const checkSession = async () => {
// Only run on client
if (import.meta.server) return
try {
const { data } = await useFetch('/api/auth/session')
if (data.value?.user) {
state.value.user = data.value.user
state.value.isAuthenticated = true
}
// Clear any previous session errors on success
state.value.sessionError = null
state.value.lastSessionCheck = Date.now()
} catch (err) {
console.error('Session check failed:', err)
// Surface error to UI with user-friendly message
state.value.sessionError = 'Unable to verify session. Please check your connection.'
state.value.lastSessionCheck = Date.now()
}
}
return {
// State (readonly)
user: computed(() => state.value.user),
isAuthenticated: computed(() => state.value.isAuthenticated),
isLoading: computed(() => state.value.isLoading),
error: computed(() => state.value.error),
sessionError: computed(() => state.value.sessionError),
lastSessionCheck: computed(() => state.value.lastSessionCheck),
// Methods
login,
logout,
checkSession
}
}
// Usage in components:
// const { sessionError, lastSessionCheck } = useAuth()
//
// Show banner when session checks fail:
// <div v-if="sessionError" class="error-banner">{{ sessionError }}</div>
Related skills
How it compares
Choose nuxt-data over generic Vue skills when implementing Nuxt 4-specific SSR data fetching and useState singleton patterns.
FAQ
What is the useState vs ref rule in nuxt-data?
nuxt-data mandates useState with a unique key for shared global state like auth users. Using ref inside a composable creates a new instance per call and breaks SSR-safe singleton patterns across components.
Why does nuxt-data discuss shallow reactivity?
nuxt-data explains that Nuxt 4 defaults useFetch data to shallow refs, so direct property mutation won't trigger updates. Fixes include deep:true, replacing the whole value, or calling refresh after a PATCH request.
Which reference files does nuxt-data load?
nuxt-data loads references/composables.md for state patterns, references/data-fetching.md for reactive API calls, and references/pinia-integration.md for store setup and SSR persistence in Nuxt 4.