useAction
useAction is the primary client-side composable for calling server actions. It provides reactive state management, lifecycle callbacks, and a consistent interface for any HTTP method. It is auto-imported in all Nuxt components, pages, and composables.
Working example
See useAction with CRUD operations in the example /actions page -- create, list, toggle, and delete todos.
Basic Usage
Import a typed action reference from #actions and pass it to useAction. Input and output types are inferred end-to-end from the server action — no manual generics, and the HTTP method comes from the action itself. The composable returns reactive refs and execution functions immediately -- no request is made until you call execute or executeAsync.
<script setup lang="ts">
import { createTodo } from '#actions'
const { execute, data, error, status } = useAction(createTodo)
const title = ref('')
async function addTodo() {
await execute({ title: title.value })
}
</script>
<template>
<form @submit.prevent="addTodo">
<input v-model="title" placeholder="What needs to be done?" />
<button :disabled="status === 'executing'">Add</button>
</form>
<p v-if="data">Created: {{ data.title }}</p>
<p v-if="error" style="color: red;">{{ error.message }}</p>
</template>TypeScript Generics (string-path fallback)
For internal actions, prefer the typed reference from #actions shown above — types are inferred with zero generics. When you call a plain string path (an external endpoint, or an action you don't import a reference for), useAction accepts two type parameters as a fallback:
useAction<TInput, TOutput>(path, options?)| Parameter | Description |
|---|---|
TInput | The shape of the data you pass to execute(). Defaults to void. |
TOutput | The shape of the data the server returns on success. Defaults to unknown. |
When you provide these generics, every ref and callback is fully typed:
interface CreateTodoInput {
title: string
priority: 'low' | 'medium' | 'high'
}
interface Todo {
id: number
title: string
priority: 'low' | 'medium' | 'high'
done: boolean
createdAt: string
}
const { execute, data } = useAction<CreateTodoInput, Todo>('/api/todos', {
method: 'POST',
onSuccess(todo) {
// todo is typed as Todo
console.log(todo.id, todo.title)
},
})
// execute expects CreateTodoInput
await execute({ title: 'Buy groceries', priority: 'high' })
// data.value is typed as Todo | null
console.log(data.value?.createdAt)For actions with no input (such as fetching a list), set TInput to void:
const { execute, data } = useAction<void, Todo[]>('/api/todos', {
method: 'GET',
})
// No argument needed
await execute()Return Values
useAction returns an object with the following properties:
execute
execute: (input: TInput) => Promise<ActionResult<TOutput>>Calls the server action and updates all reactive refs. Returns the full ActionResult, which is either { success: true, data: TOutput } or { success: false, error: ActionError }. This function never throws -- errors are captured in the error ref and the result object.
const result = await execute({ title: 'Test' })
if (result.success) {
console.log('Created:', result.data.id)
} else {
console.log('Failed:', result.error.code)
}executeAsync
executeAsync: (input: TInput) => Promise<TOutput>Similar to execute, but returns the data directly on success and throws ActionError on failure. Use this when you prefer try/catch over checking result.success:
try {
const todo = await executeAsync({ title: 'Test' })
// todo is directly typed as Todo
console.log(todo.id)
} catch (err) {
// err is ActionError
const error = err as ActionError
console.error(error.code, error.message)
}WARNING
When using executeAsync, unhandled rejections will propagate if you do not wrap the call in try/catch. Prefer execute when you want errors handled via refs and callbacks without risk of unhandled exceptions.
data
data: Ref<TOutput | null>A reactive ref holding the most recent successful response. Starts as null and updates whenever an execute call succeeds. Remains at its last successful value even if a subsequent call fails.
error
error: Ref<ActionError | null>A reactive ref holding the most recent error. Starts as null. Set to null at the beginning of each execute call, and updated if the call fails. Cleared on the next successful call.
status
status: Ref<ActionStatus>
// ActionStatus = 'idle' | 'executing' | 'success' | 'error'Tracks the lifecycle of the action:
idle --> executing --> success
--> erroridle-- Initial state, or after callingreset()executing-- A request is in flightsuccess-- The most recent request succeedederror-- The most recent request failed
reset
reset: () => voidResets all reactive state to its initial values:
const { execute, data, error, status, reset } = useAction('/api/todos')
await execute({ title: 'test' })
// data.value = { ... }, status.value = 'success'
reset()
// data.value = null, error.value = null, status.value = 'idle'HTTP Methods
By default, useAction sends POST requests. Set the method option to use a different HTTP method.
// POST (default) -- input sent as request body
const { execute } = useAction('/api/todos', { method: 'POST' })
// GET -- input sent as query parameters
const { execute } = useAction('/api/todos', { method: 'GET' })
// PUT -- input sent as request body
const { execute } = useAction('/api/todos/1', { method: 'PUT' })
// PATCH -- input sent as request body
const { execute } = useAction('/api/todos/1', { method: 'PATCH' })
// DELETE -- input sent as request body
const { execute } = useAction('/api/todos/1', { method: 'DELETE' })How input is transmitted:
| Method | Input location |
|---|---|
GET, HEAD | Query parameters (?key=value) |
POST, PUT, PATCH, DELETE | JSON request body |
This matches the convention used by defineAction on the server, which reads from getQuery for GET/HEAD and readBody for everything else.
Callbacks
Four optional callbacks let you react to action lifecycle events without watching refs:
onExecute
Fires immediately when execute is called, before the HTTP request is made. Useful for logging or clearing previous UI state.
const { execute } = useAction('/api/todos', {
onExecute(input) {
console.log('Sending:', input)
formErrors.value = {}
},
})onSuccess
Fires when the server returns a successful result. Receives the typed response data.
const { execute } = useAction<CreateTodoInput, Todo>('/api/todos', {
method: 'POST',
onSuccess(todo) {
toast.success(`Created "${todo.title}"`)
router.push(`/todos/${todo.id}`)
},
})onError
Fires when the action fails (validation error, server error, or network error). Receives the ActionError object.
const { execute } = useAction('/api/todos', {
onError(error) {
if (error.code === 'VALIDATION_ERROR' && error.fieldErrors) {
// Display per-field errors
Object.entries(error.fieldErrors).forEach(([field, messages]) => {
setFieldError(field, messages[0])
})
} else {
toast.error(error.message)
}
},
})onSettled
Fires after every execution, whether it succeeded or failed. Receives the full ActionResult. Useful for cleanup tasks that should happen regardless of outcome.
const { execute } = useAction('/api/export', {
onExecute() {
showProgressBar.value = true
},
onSettled(result) {
showProgressBar.value = false
console.log('Completed with success:', result.success)
},
})Callback execution order
When an action succeeds: onExecute -> onSuccess -> onSettled
When an action fails: onExecute -> onError -> onSettled
Loading States
Use the status ref to drive loading UI in your templates:
<script setup lang="ts">
const { execute, data, error, status } = useAction<void, Todo[]>(
'/api/todos',
{ method: 'GET' },
)
// Computed helpers for cleaner templates
const isLoading = computed(() => status.value === 'executing')
const isIdle = computed(() => status.value === 'idle')
</script>
<template>
<div>
<button @click="execute()" :disabled="isLoading">
{{ isLoading ? 'Loading...' : 'Fetch Todos' }}
</button>
<!-- Loading skeleton -->
<div v-if="isLoading" class="skeleton">
<div v-for="i in 3" :key="i" class="skeleton-row" />
</div>
<!-- Results -->
<ul v-else-if="data">
<li v-for="todo in data" :key="todo.id">
{{ todo.title }}
</li>
</ul>
<!-- Empty state -->
<p v-else-if="isIdle">Click the button to load todos.</p>
<!-- Error state -->
<div v-if="error" class="error">
<p>{{ error.message }}</p>
<button @click="execute()">Retry</button>
</div>
</div>
</template>Disabling buttons during execution
A common pattern is to disable submit buttons while an action is in flight:
<button
type="submit"
:disabled="status === 'executing'"
:class="{ 'opacity-50': status === 'executing' }"
>
<span v-if="status === 'executing'">Saving...</span>
<span v-else>Save</span>
</button>Complete Example: Contact Form
This example brings together input handling, loading states, error display, field errors, and success feedback.
<script setup lang="ts">
interface ContactInput {
name: string
email: string
message: string
}
interface ContactResponse {
ticketId: string
}
const form = reactive<ContactInput>({
name: '',
email: '',
message: '',
})
const fieldErrors = ref<Record<string, string[]>>({})
const { execute, status, data } = useAction<ContactInput, ContactResponse>(
'/api/contact',
{
method: 'POST',
onExecute() {
fieldErrors.value = {}
},
onSuccess(response) {
toast.success(`Message sent! Ticket: ${response.ticketId}`)
// Reset form
form.name = ''
form.email = ''
form.message = ''
},
onError(error) {
if (error.fieldErrors) {
fieldErrors.value = error.fieldErrors
} else {
toast.error(error.message)
}
},
},
)
function fieldError(field: string): string | undefined {
return fieldErrors.value[field]?.[0]
}
</script>
<template>
<form @submit.prevent="execute(form)">
<div>
<label>Name</label>
<input v-model="form.name" />
<span v-if="fieldError('name')" class="error">{{ fieldError('name') }}</span>
</div>
<div>
<label>Email</label>
<input v-model="form.email" type="email" />
<span v-if="fieldError('email')" class="error">{{ fieldError('email') }}</span>
</div>
<div>
<label>Message</label>
<textarea v-model="form.message" rows="4" />
<span v-if="fieldError('message')" class="error">{{ fieldError('message') }}</span>
</div>
<button type="submit" :disabled="status === 'executing'">
{{ status === 'executing' ? 'Sending...' : 'Send Message' }}
</button>
<p v-if="data" class="success">
Thank you! Your ticket ID is {{ data.ticketId }}.
</p>
</form>
</template>The corresponding server action:
// server/api/contact.post.ts
import { z } from 'zod'
export default defineAction({
input: z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Please enter a valid email'),
message: z.string().min(10, 'Message must be at least 10 characters'),
}),
handler: async ({ input }) => {
const ticketId = `TICKET-${Date.now()}`
await sendEmail({
to: 'support@example.com',
subject: `Contact: ${input.name}`,
body: `From: ${input.email}\n\n${input.message}`,
})
return { ticketId }
},
})Best Practices
Provide explicit type parameters
Always specify TInput and TOutput for full type safety across execute, data, callbacks, and destructured return values:
// Preferred: explicit types
const { execute, data } = useAction<
{ title: string; priority: string },
{ id: number; title: string }
>('/api/todos', { method: 'POST' })
// Avoid: relying on defaults leaves data as unknown
const { execute, data } = useAction('/api/todos')Handle loading states in UI
Always account for the executing state. Disabling buttons and showing spinners prevents duplicate submissions and communicates progress to users.
Use onError for toast notifications and side effects
Keep the template clean by handling transient feedback (toasts, redirects, analytics) in onError and onSuccess. Reserve the error ref for persistent, inline error display.
Use reset() for multi-step forms
After a successful form submission, call reset() to return the composable to its initial state, ready for the next entry:
const { execute, reset, status, data } = useAction('/api/entries', {
method: 'POST',
onSuccess() {
// Clear form inputs
form.name = ''
form.value = ''
// Reset composable state
reset()
},
})Avoid calling execute in setup without user intent
useAction does not make a request on mount. If you need to fetch data on page load, consider using Nuxt's built-in useFetch or useAsyncData. Reserve useAction for user-triggered mutations and queries.
Cancelling Requests
cancelPrevious
For type-ahead search and rapid filtering, cancelPrevious: true aborts the previous in-flight request every time execute() is called again -- a stale response can never overwrite fresher data. It is shorthand for dedupe: 'cancel' (an explicit dedupe option wins):
const search = useAction(searchTodos, { cancelPrevious: true, debounce: 150 })cancel()
cancel() aborts the in-flight request without clearing data/error state (unlike reset(), which aborts and wipes everything). The aborted call resolves with an ABORT_ERROR result and status returns to idle:
const { execute, cancel, data } = useAction(searchTodos)
execute({ q: 'nuxt' })
cancel() /* data keeps its previous value */Retry with Backoff
The retry object accepts growth strategies for flaky networks. Delays can grow per attempt, get capped, and be jittered to avoid thundering-herd retries:
const { execute } = useAction(syncOrders, {
retry: {
count: 4,
delay: 300, // base delay
backoff: 'exponential', // 300ms, 600ms, 1200ms, 2400ms
maxDelay: 5000, // cap each delay
jitter: true, // randomize within [50%, 100%]
statusCodes: [502, 503, 504],
},
})backoff: 'linear' grows arithmetically (300, 600, 900, ...); the default 'fixed' keeps the base delay.
ofetch version requirement
backoff, maxDelay, and jitter rely on ofetch >= 1.4 (bundled with Nuxt 3.13+). On older Nuxt 3.x runtimes these options silently fall back to ofetch's own delay handling — plain delay works everywhere.
Next Steps
- Optimistic Updates -- Instant UI feedback with
useOptimisticAction - Error Handling -- Detailed error handling patterns
- Observability -- Global hooks for every action call
- useAction API -- Full API reference