Valet

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:

CodeMeaning
VALIDATION_ERRORArguments failed runtime validation
VERSION_CONFLICTDocument was modified by another client since you read it
NOT_FOUNDDocument ID does not exist
UNAUTHORIZEDAuth token is missing, expired, or invalid
HANDLER_ERRORThe mutation handler threw an error on the server
DISCONNECTEDConnection was lost before the server responded
UNKNOWNUnexpected 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:

StateMeaning
connectedWebSocket is open, data is flowing
connectingInitial connection in progress
reconnectingConnection was lost, attempting to reconnect
disconnectedNot 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:

  1. The client resubscribes to all queries and waits for fresh server state.
  2. Pending mutations are rebased -- each handler re-executes against the current server state.
  3. Rebased mutations are sent to the server as function calls.
  4. 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.

On this page