Domphy

Data Mutations

Mutations with @domphy/query

The most common pattern: perform mutations using createMutation from @domphy/query, then invalidate the relevant queries to reflect the updated state:

import { QueryClient } from "@domphy/query"
import { createMutation } from "@domphy/query/domphy"

const queryClient = new QueryClient()

const createPost = createMutation(queryClient, {
  mutationFn: (data: PostInput) => api.post("/posts", data),
  onSuccess: (post) => {
    // Invalidate the posts list — it will refetch automatically
    queryClient.invalidateQueries({ queryKey: ["posts"] })
    router.navigate({ to: `/posts/${post.id}` })
  },
})

Route action pattern

For form submissions that navigate after success, use a route-level action:

import { createRoute } from "@domphy/router"
import { createForm } from "@domphy/form/domphy"

const newPostRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/posts/new",
  component: () => {
    const form = createForm<PostInput>({
      defaultValues: { title: "", body: "" },
      onSubmit: async ({ value }) => {
        const post = await api.post("/posts", value)
        // Navigate after successful creation
        router.navigate({ to: `/posts/${post.id}` })
      },
    })

    return {
      form: [
        // form fields...
        {
          button: (l) => form.isSubmitting(l) ? "Creating…" : "Create post",
          type: "submit",
          disabled: (l) => !form.canSubmit(l),
        },
      ],
      onSubmit: (e: Event) => { e.preventDefault(); form.handleSubmit() },
    }
  },
})

Optimistic mutations

Update the cache immediately, then revert if the server request fails:

import { QueryClient } from "@domphy/query"
import { createMutation } from "@domphy/query/domphy"

interface Todo { id: string; text: string; done: boolean }

const queryClient = new QueryClient()

const toggleTodo = createMutation(queryClient, {
  mutationFn: (id: string) => api.patch(`/todos/${id}/toggle`),

  onMutate: async (id) => {
    // Cancel any in-flight refetches
    await queryClient.cancelQueries({ queryKey: ["todos"] })

    // Snapshot the current state
    const previous = queryClient.getQueryData<Todo[]>(["todos"])

    // Optimistically update the cache
    queryClient.setQueryData<Todo[]>(["todos"], (todos = []) =>
      todos.map((t) => t.id === id ? { ...t, done: !t.done } : t)
    )

    return { previous }
  },

  onError: (_err, _id, context) => {
    // Revert on error
    queryClient.setQueryData(["todos"], context?.previous)
  },

  onSettled: () => {
    // Always refetch after success or error to ensure consistency
    queryClient.invalidateQueries({ queryKey: ["todos"] })
  },
})

Mutation state in the UI

const TodoItem = (todo: Todo) => ({
  li: [
    {
      input: null,
      type: "checkbox",
      checked: todo.done,
      disabled: (l) => toggleTodo.isPending(l),
      onChange: () => toggleTodo.mutate(todo.id),
    },
    { span: todo.text },
    {
      span: "Saving…",
      hidden: (l) => !toggleTodo.isPending(l),
      style: { fontSize: "0.75rem", opacity: 0.6 },
    },
  ],
})

Mutation + route loader coordination

After a mutation, reload the route's loader data to reflect the change:

const queryClient = new QueryClient()

const deletePost = createMutation(queryClient, {
  mutationFn: (id: string) => api.delete(`/posts/${id}`),
  onSuccess: async () => {
    // Invalidate the query cache
    await queryClient.invalidateQueries({ queryKey: ["posts"] })
    // Navigate to the list (the list route's loader will refetch)
    router.navigate({ to: "/posts" })
  },
})

Or, if you're using route loaders (not @domphy/query), force the current route to reload:

const queryClient = new QueryClient()

const deletePost = createMutation(queryClient, {
  mutationFn: (id: string) => api.delete(`/posts/${id}`),
  onSuccess: async () => {
    // Force the route to reload its loader
    await router.invalidate()
    router.navigate({ to: "/posts" })
  },
})

router.invalidate() marks all route loaders as stale and re-runs them on the next render.

Pending mutations

Track all in-flight mutations by subscribing to the MutationCache:

import { QueryClient } from "@domphy/query"
import { toState } from "@domphy/core"

const queryClient = new QueryClient()
const mutatingCount = toState(0)

queryClient.getMutationCache().subscribe(() => {
  mutatingCount.set(queryClient.isMutating())
})

const SaveIndicator = {
  div: "Saving…",
  hidden: (l) => mutatingCount.get(l) === 0,
}

Global mutation callbacks

Register callbacks on the QueryClient to handle all mutations centrally (e.g., show toast notifications):

import { QueryClient, MutationCache } from "@domphy/query"

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onError: (error) => {
      toast.error(`Operation failed: ${error.message}`)
    },
    onSuccess: () => {
      toast.success("Saved")
    },
  }),
})

Error handling

const queryClient = new QueryClient()

const saveForm = createMutation(queryClient, {
  mutationFn: submitFormData,
  onError: (error: ApiError) => {
    if (error.status === 422) {
      // Validation error — display field errors
      error.fields?.forEach(({ field, message }) => {
        form.form.setFieldMeta(field, (meta) => ({
          ...meta,
          errors: [message],
        }))
      })
    } else {
      // Unexpected error — show generic toast
      toast.error("Something went wrong. Please try again.")
    }
  },
})