Skip to main content

Add a mutation

Write data from a form and watch the live query update on its own.

Add a form that creates todos. The list from the previous page updates by itself.

Add the mutation

Add create to convex/todos.ts. Keep list from the previous page.

convex/todos.ts
import { ConvexError, v } from 'convex/values'

import { mutation, query } from './_generated/server'

export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query('todos').withIndex('by_created').order('desc').take(50)
  },
})

export const create = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    const text = args.text.trim()
    if (!text || text.length > 200) {
      throw new ConvexError({
        code: 'INVALID_TODO_TEXT',
        message: 'Todo text must contain 1 to 200 characters',
      })
    }

    return await ctx.db.insert('todos', {
      text,
      completed: false,
      createdAt: Date.now(),
    })
  },
})

Connect the form

Replace the page:

app/pages/index.vue
<script setup lang="ts">
import { api } from '#convex/api'

const text = ref('')
const { data: todos, status, error } = useConvexQuery(api.todos.list)
const { mutate, pending, error: createError } = useConvexMutation(api.todos.create)

async function submit() {
  try {
    await mutate({ text: text.value })
    text.value = ''
  } catch {
    // createError now holds the error. The template shows it.
  }
}
</script>

<template>
  <main>
    <h1>Todos</h1>

    <form @submit.prevent="submit">
      <input v-model="text" maxlength="200" aria-label="Todo text" />
      <button :disabled="pending || !text.trim()">
        {{ pending ? 'Adding…' : 'Add todo' }}
      </button>
    </form>

    <p v-if="createError">{{ createError.message }}</p>

    <p v-if="status === 'pending'">Loading todos…</p>
    <p v-else-if="error">Could not load todos.</p>
    <ul v-else>
      <li v-for="todo in todos" :key="todo._id">
        {{ todo.text }}
      </li>
    </ul>
  </main>
</template>

useConvexMutation returns an object. Destructure what you need:

NameWhat it is
mutateCalls the mutation. Returns its result, or throws on error.
pendingtrue while the latest call runs.
errorThe latest error as a ConvexCallError, or undefined.
dataThe latest result, or undefined.
status'idle', 'pending', 'success', or 'error'.
resetClears data and error.

Rename fields when a page has more than one query or mutation, as error: createError does here.

Try it

  1. Type a todo and click Add todo. It appears in the list.
  2. Open the page in a second browser tab. Add a todo in one tab. It appears in both.

You did not call refresh(). Convex saw that the mutation changed the todos table. It sent the new todos.list result to every open page.

When the mutation rejects the text, mutate throws a ConvexCallError. Its message is the message from your ConvexError: "Todo text must contain 1 to 200 characters". Its code is INVALID_TODO_TEXT. See error handling.

Your app now reads and writes live data. If it needs sign-in, continue with Add authentication. Otherwise, go to next steps.