Capabilities/Data mutations

Data mutations

Forms, validation, and round-tripping to Python.

Forms

BionicJS recommends using standard React Server Functions (or equivalent mutation patterns like React Query) to send data from the client to the server.

A mutation from the client
1const result = await api.users.updateProfile({
2  id: "user_123",
3  name: form.name,
4});

Mutations are typed RPC calls, the same as any other fetch. The form submits to server/api/ endpoints, where input is validated using Zod before being processed by the h3 handler and passed down to the database or Python layer.

Validation

Validation happens at the server boundary, not in the component. The schema that validates the input is the same schema that types the call - so what the client sends is what the server checked:

server/api/users/update-profile.ts
1const UpdateProfileSchema = z.object({
2  id: z.string(),
3  name: z.string().min(1).max(80),
4});
5
6export default defineEventHandler(async (event) => {
7  const body = await readValidatedBody(event, UpdateProfileSchema.safeParse);
8  const row = await db.update(users)
9    .set({ name: body.name })
10    .where(eq(users.id, body.id))
11    .returning();
12  return row;
13});

The flow is predictable: the client sends a typed payload, the server validates it against the schema, and an invalid request fails fast - before any database write or Python roundtrip.

To Python

When the mutation needs intelligence - a generated embedding, a content rewrite, a classification - it crosses to Python through the typed RPC boundary:

A mutation that needs Python
1export default defineEventHandler(async (event) => {
2  const body = await readValidatedBody(event, UpdateProfileSchema.safeParse);
3  const summary = await generateSummary(body.bio); // -> Python RPC
4  const row = await db.update(profiles)
5    .set({ summary })
6    .where(eq(profiles.id, body.id));
7  return row;
8});

The mutation is a single handler. The database write and the Python call are both typed, both contained in one file, and both executed in one request - no second service, no untyped JSON handshake.

ⓘ
If the Python work is slow, don't hold the request open. Return immediately, enqueue a background job, and send the result over WebSocket when the worker finishes. The mutation boundary stays the same; only the timing changes.