6. Write server data
Goal: create a task on the server, and make the list update before the request even comes back.
The mutation primitive
mutation is query's counterpart for writes. Same shape, triggered explicitly.
import { CraftHttpClient, mutation } from '@craft-ng/core';
const { createTask } =
yield *
mutation('createTask', {
method: (payload: { title: string }) => payload,
loader: function* ({ params }) {
return yield* CraftHttpClient.post(({ response }) => ({
url: '/api/tasks',
body: params,
success: response<Task>(),
}));
},
});
yield * createTask.mutate({ title: 'Write step 6' });
createTask.isLoading();
createTask.value();
createTask.exception();method is the entry point: it takes what the caller passes and returns what the loader receives as params. It is also where you can reject input before any request happens (see below).
Making the list react
The interesting part is not the mutation, it's wiring it to the query. That's an insertion — insertReactOnMutation:
import { insertReactOnMutation, query } from '@craft-ng/core';
const { tasksQuery } =
yield *
query(
'tasksQuery',
{
params: () => ({ done: false }),
loader: function* () {
return yield* CraftHttpClient.get(({ response }) => ({
url: '/api/tasks',
success: response<Task[]>(),
}));
},
},
insertReactOnMutation(createTask, {
reload: { onMutationSuccess: true },
}),
);The query now reloads itself whenever createTask succeeds. No subscription, no event bus, no manual refetch() call at the call site.
Optimistic updates
Reloading costs a round-trip. optimisticPatch applies the change immediately and reverts it if the mutation fails:
insertReactOnMutation(renameTask, {
optimisticPatch: {
title: ({ mutationParams }) => mutationParams.title,
},
reload: { onMutationException: true },
});While renameTask is in flight, tasksQuery.value() already shows the new title. If it throws, the query reloads to get the truth back.
Rejecting bad input
You rarely want to send a request you know will fail. Return a craftException from method and the loader never runs:
import { craftException } from '@craft-ng/core';
const createTask = yield* mutation('createTask', {
method: (payload: { title: string }) =>
payload.title.trim().length === 0
? craftException({ code: 'TITLE_REQUIRED' }, { received: payload.title })
: payload,
loader: /* … */,
});
yield* createTask.mutate({ title: ' ' });
createTask.hasException(); // true
createTask.exceptions().params?.TITLE_REQUIRED;Note the shape: exceptions() is split by origin — params for what your method rejected, loader for what the request produced. Both are typed from the codes you declared, so the compiler knows TITLE_REQUIRED exists and that TITLE_TOO_LONG doesn't.
Or let a schema do it
Hand-written guards get long as soon as there are several fields. Declare a schema instead and the primitive validates the argument for you:
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().trim().min(1).max(80),
});
const createTask = yield* mutation('createTask', {
methodSchema: CreateTaskSchema,
method: (payload) => payload, // already validated and typed by the schema
loader: /* … */,
});methodSchema validates what mutate(...) receives, and method then gets the schema's output value — so a coercion or a .trim() in the schema is reflected in the type.
Any library implementing StandardSchemaV1 works — Zod, Valibot, Effect, or a hand-written { '~standard': … } object. None of them becomes a dependency of @craft-ng. Queries have the same hooks for their reactive params (paramsSchema) and their result (loaderSchema).
Use a schema when the shape itself is the rule, a craftException from method when the rule is business logic — "this title already exists in the current project" is not something a schema can know. See Schema validation.
Exceptions as values
A craft exception is a value you declared and expect to handle. An error is the unexpected kind. Keeping the two apart is what makes the exhaustiveness checks later possible — see Exceptions.
What you gained
A write path that owns its loading and failure state, and a declarative link between writes and reads.