Error Handling
Handle errors from mutations, connections, and sync.
// components/CreateTodo.tsx
import { useMutation } from '../_generated/valet/react'
import { api } from '../_generated/valet/api'
function CreateTodo() {
const createTodo = useMutation(api.todos.create, {
onError: (error, args) => {
console.error(
`Failed to create "${args.title}":`,
error.code,
error.message
)
},
onSuccess: (result, args) => {
console.log(`Created todo "${args.title}" with id:`, result)
},
onSettled: (result, error, args) => {
// Runs after both success and error
console.log('Mutation complete for:', args.title)
},
})
return (
<div>
<button onClick={() => createTodo.mutate({ title: 'New task' })}>
Add Todo
</button>
{createTodo.error && (
<p>
Error: {createTodo.error.message}
<button onClick={createTodo.reset}>Dismiss</button>
</p>
)}
</div>
)
}Mutation errors surface through callbacks and the error property on UseMutationResult. Connection and sync errors are handled at the provider level.
Mutation errors
The SyncError type
When a mutation fails, the error is a SyncError with two fields:
interface SyncError {
code: string
message: string
}Common error codes:
| Code | Meaning |
|---|---|
VALIDATION_ERROR | Arguments failed runtime validation |
VERSION_CONFLICT | Document was modified by another client since you read it |
NOT_FOUND | Document ID does not exist |
UNAUTHORIZED | Auth token is missing, expired, or invalid |
HANDLER_ERROR | The mutation handler threw an error on the server |
DISCONNECTED | Connection was lost before the server responded |
UNKNOWN | Unexpected server error |
useMutation callbacks
The useMutation hook accepts three callbacks:
// components/TodoActions.tsx
import { useMutation } from '../_generated/valet/react'
import { api } from '../_generated/valet/api'
function TodoActions({ todoId }: { todoId: string }) {
const deleteTodo = useMutation(api.todos.remove, {
onSuccess: (result, args) => {
// Called when the server confirms the mutation
},
onError: (error, args) => {
// Called when the mutation fails
// error is a SyncError with .code and .message
if (error.code === 'NOT_FOUND') {
// Document was already deleted by another client
}
},
onSettled: (result, error, args) => {
// Called after either success or error
// Useful for cleanup like hiding spinners
},
})
return <button onClick={() => deleteTodo.mutate({ _id: todoId })}>Delete</button>
}The error and reset properties
UseMutationResult exposes error and reset for rendering error UI:
// components/CreateForm.tsx
import { useMutation } from '../_generated/valet/react'
import { api } from '../_generated/valet/api'
import { useState } from 'react'
function CreateForm() {
const [title, setTitle] = useState('')
const createTodo = useMutation(api.todos.create)
const handleSubmit = async () => {
try {
await createTodo.mutate({ title })
setTitle('')
} catch {
// Error is also available via createTodo.error
}
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmit()
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit" disabled={createTodo.isLoading}>
{createTodo.isLoading ? 'Creating...' : 'Create'}
</button>
{createTodo.error && (
<div>
<p>{createTodo.error.message}</p>
<button type="button" onClick={createTodo.reset}>
Dismiss
</button>
</div>
)}
</form>
)
}The error property holds the SyncError from the last failed mutation. It persists until you call reset() or the next mutation succeeds. Calling mutate() again clears the previous error automatically.
Connection errors
useConnectionState
Monitor the WebSocket connection with useConnectionState():
// components/ConnectionBanner.tsx
import { useConnectionState } from '../_generated/valet/react'
function ConnectionBanner() {
const state = useConnectionState()
if (state === 'connected') return null
const messages: Record<string, string> = {
connecting: 'Connecting to server...',
reconnecting: 'Connection lost. Reconnecting...',
disconnected: 'Disconnected. Check your network connection.',
}
return <div className="connection-banner">{messages[state]}</div>
}The four connection states:
| State | Meaning |
|---|---|
connected | WebSocket is open, data is flowing |
connecting | Initial connection in progress |
reconnecting | Connection was lost, attempting to reconnect |
disconnected | Not connected, no reconnection in progress |
Automatic reconnection
Valet reconnects automatically with exponential backoff when the connection drops. The default configuration:
- Up to 10 reconnection attempts
- Initial delay of 1 second, doubling each attempt
- Maximum delay of 30 seconds
After reconnecting, the client resubscribes to all active queries and receives updated data. Pending mutations (queued while offline) are rebased against the server state and flushed.
Protocol version mismatch
When the client and server run different protocol versions, the onProtocolMismatch callback fires:
// App.tsx
import { ValetProvider } from './_generated/valet/react'
function App() {
return (
<ValetProvider
url={process.env.VALET_PROJECT_URL!}
onProtocolMismatch={(serverVersion, clientVersion) => {
// Server and client are on different protocol versions
// Prompt the user to refresh the page
if (confirm('A new version is available. Reload now?')) {
window.location.reload()
}
}}
>
<MyApp />
</ValetProvider>
)
}A protocol mismatch usually means the server was updated. The fix is to reload the page to get the latest client code.
Initialization errors
The generated ValetProvider accepts loadingFallback and errorFallback props to handle initialization failures (WASM loading errors, database creation failures):
// App.tsx
import { ValetProvider } from './_generated/valet/react'
function App() {
return (
<ValetProvider
url={process.env.VALET_PROJECT_URL!}
loadingFallback={<div>Initializing...</div>}
errorFallback={(error) => (
<div>
<h2>Failed to start</h2>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>
Retry
</button>
</div>
)}
>
<MyApp />
</ValetProvider>
)
}The errorFallback can be a static ReactNode or a function that receives the Error. If the local database fails to initialize (for example, WASM cannot load in the current browser), Valet falls back to server-only mode where queries run on the server and mutations require an active connection.
Unrecoverable errors
The onError callback on ValetProvider fires for errors that cannot be recovered automatically, such as exceeding the maximum reconnection attempts:
// App.tsx
import { ValetProvider } from './_generated/valet/react'
function App() {
return (
<ValetProvider
url={process.env.VALET_PROJECT_URL!}
onError={(error) => {
console.error('Valet error:', error.message)
// Log to your error tracking service
}}
>
<MyApp />
</ValetProvider>
)
}Retry behavior
Valet handles retries at multiple levels:
Connection retries
The WebSocket reconnects automatically with exponential backoff. No action is required from your code. While disconnected, local queries continue to work against cached data and mutations queue locally.
Mutation retries on reconnect
Mutations made while offline are stored in the mutation log. On reconnect:
- The client resubscribes to all queries and waits for fresh server state.
- Pending mutations are rebased -- each handler re-executes against the current server state.
- Rebased mutations are sent to the server as function calls.
- If a mutation fails during rebase (the handler throws against the new state), it is marked as failed and removed from the queue.
This rebase model ensures mutations produce correct results even when the local state was stale. See How Sync Works for details on conflict resolution.
No automatic retry for online mutation errors
When the client is connected and a mutation fails (server returns an error), the error is surfaced through onError and the error property. Valet does not retry it automatically. Handle the retry in your application code if needed:
// components/RetryExample.tsx
import { useMutation } from '../_generated/valet/react'
import { api } from '../_generated/valet/api'
function RetryExample() {
const createTodo = useMutation(api.todos.create, {
onError: (error, args) => {
if (error.code === 'HANDLER_ERROR') {
// Retry after a delay
setTimeout(() => createTodo.mutate(args), 2000)
}
},
})
return (
<button onClick={() => createTodo.mutate({ title: 'New task' })}>
Create
</button>
)
}For a full list of error codes and their meanings, see the Errors reference.