Actions | Koze | Primitives Docs

Actions

Form actions, ActionError, redirects, and RPC form submissions

Form actions

Export a server function from the route <script> block and reference it with action={fn}.

<script>
  import { addItem } from '$database/items';
</script>

<form action={addItem} method="POST">
  <input type="text" name="title" required />
  <button type="submit">Add</button>
</form>

The action receives a single context object with the form data and per-request context. Destructure what you need:

import { ActionError } from '@kuratchi/koze';

export async function addItem({ formData }: { formData: FormData }): Promise<void> {
  const title = (formData.get('title') as string)?.trim();
  if (!title) throw new ActionError('Title is required');
}

The context object carries:

  • formData — the submitted form fields (always present; empty for button-triggered actions that don't carry a form body).
  • request — the underlying Request object.
  • url — the parsed URL of the incoming request.
  • params — path params for the matched route.
  • env - Cloudflare bindings for the current Worker request.
  • ctx - the Cloudflare ExecutionContext.
  • locals - request-scoped state populated by middleware.

The full action signature is fn(...args, ctx). For <form> submissions args is empty and the context object is the handler's only argument; for button-triggered actions args carries the positional call arguments and ctx stays as the trailing parameter.

If you want a reusable annotation, import ActionContext:

import type { ActionContext } from '@kuratchi/koze';

export async function addItem({ formData, env, locals }: ActionContext) {
  // env.DB, locals.user, ...
}

Augmented forms

<form action={fn} method="POST"> works without JavaScript as a normal POST-Redirect-GET form. For app-like UX, wrap the server action with augment() and bind the form to the returned action state:

<script>
  import { augment } from '@kuratchi/koze';
  import { navigateTo } from 'koze:navigation';
  import { createDatabase } from '$server/database/databases';

  let createOpen = false;

  const createDb = augment(createDatabase, {
    error() {
      createOpen = true;
    },
    success(payload) {
      createOpen = false;
      navigateTo(payload.redirectTo || '/databases', { replace: true });
    },
  });
</script>

<Dialog bind:open={createOpen}>
  <form action={createDb} method="POST">
    <input name="name" required />

    if (createDb.error) {
      <p class="error">{createDb.error}</p>
    }

    <button type="submit" disabled={createDb.pending}>
      if (createDb.pending) {
        Creating...
      } else {
        Create database
      }
    </button>
  </form>
</Dialog>

augment(action, hooks) returns an action state object:

interface AugmentedActionState {
  pending: boolean;
  success: boolean;
  error: string | undefined;
}

The compiler maps the alias (createDb) back to the wrapped server action (createDatabase). The submitted form still carries _action and still works as a native form if JavaScript is unavailable.

Stateful behavior

Stateful augmented forms behave like this:

  • Submit uses fetch() with the compiler-injected _action field.
  • Before the request starts, .pending becomes true, .success becomes false, and .error is cleared.
  • Submit buttons inside the form are disabled while the request is in flight. Buttons that were already disabled are restored to disabled.
  • On success, .pending becomes false and .success becomes true.
  • On ActionError, .pending becomes false, .success becomes false, and .error contains the user-facing message.
  • The route re-renders the pieces of UI that read .pending, .success, or .error.

Use this for dialogs, create/delete forms, auth forms, uploads, and any flow where the user should see the button disable, a spinner, inline errors, or a success message before the next navigation.

Hooks

The second argument can define lifecycle hooks:

const save = augment(saveSettings, {
  pending(payload) {},
  success(payload) {},
  error(payload) {},
  settled(payload) {},
});

Each hook receives:

interface AugmentedActionHookContext<T = unknown> {
  action: string;
  form?: HTMLFormElement;
  response?: Response;
  result?: T;
  error?: string;
  redirectTo?: string | null;
  redirectStatus?: number | null;
}

Use hooks for dialog state, toast dispatch, delayed navigation, or route refreshes:

<script>
  import { augment } from '@kuratchi/koze';
  import { navigateTo } from 'koze:navigation';
  import { deleteSite } from '$server/database/sites';

  let deleteOpen = false;

  const removeSite = augment(deleteSite, {
    error() {
      deleteOpen = true;
    },
    success(payload) {
      deleteOpen = false;
      navigateTo(payload.redirectTo || '/sites', { replace: true });
    },
  });
</script>

Note: Stateful augment() does not automatically navigate on action redirects. Redirect metadata is exposed as payload.redirectTo and payload.redirectStatus so your success UI can render first. Call navigateTo(...), location.assign(...), or another navigation helper from the success hook when the flow should leave the page.

HTML augmentation

The older form attribute still exists:

<form action={saveSettings} method="POST" augment>
  ...
</form>

This mode fetches the form, disables submit buttons during the request, and swaps in the returned HTML or follows redirects. It does not create a route-local state object and does not run lifecycle hooks.

Prefer const state = augment(action, hooks) for new product UI. Use the bare augment attribute only when you want progressive HTML replacement and do not need .pending, .success, .error, or hooks.

Redirect after write

Use redirect() for POST-redirect-get flows:

import { redirect } from 'koze:navigation';

export async function createItem({ formData }: { formData: FormData }): Promise<void> {
  const id = await db.items.insert({ title: formData.get('title') });
  redirect(`/items/${id}`);
}

Action errors

Throw ActionError for user-facing validation failures:

import { ActionError } from '@kuratchi/koze';

export async function signIn({ formData }: { formData: FormData }) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  if (!email || !password) throw new ActionError('Email and password are required');
}

For native and HTML-augmented forms, action state is exposed in the template under the function name after the server re-renders the route:

<form action={signIn}>
  if (signIn.error) {
    <p class="error">{signIn.error}</p>
  }
  <input type="email" name="email" />
  <input type="password" name="password" />
  <button type="submit">Sign in</button>
</form>

For stateful augment() forms, read the alias returned by augment():

<script>
  import { augment } from '@kuratchi/koze';
  import { signIn } from '$server/database/auth';

  const signInAction = augment(signIn);
</script>

<form action={signInAction}>
  if (signInAction.error) {
    <p class="error">{signInAction.error}</p>
  }
  <button disabled={signInAction.pending}>Sign in</button>
</form>

Page errors

Throw PageError when a route should render an HTTP error page:

import { PageError } from '@kuratchi/koze';

if (!post) throw new PageError(404);
if (!post.isPublished && !currentUser?.isAdmin) throw new PageError(403, 'Admin only');

Validated RPC input

For client-callable route RPC functions, declare a companion schemas object in the same server module. Each schema key must match the exported function name exactly.

import { schema, type InferSchema } from '@kuratchi/koze';

export const schemas = {
  createPost: schema({
    title: schema.string().min(1),
    content: schema.string().min(1),
  }),
};

export async function createPost(data: InferSchema<typeof schemas.createPost>) {
  return { slug: data.title.toLowerCase().replace(/ /g, '-') };
}

Use the function normally from the route template:

<script>
  const result = await createPost({ title: 'Hello', content: 'World' });
</script>

<p>Created: {result.slug}</p>

Rules:

  • Schema-backed RPCs accept one object argument.
  • The schema object must be exported as schemas.
  • Keys in schemas must match the RPC function names.
  • If validation fails, Koze returns 400 and does not execute the function.

Current TypeScript pattern:

import { schema, type InferSchema } from '@kuratchi/koze';

export const schemas = {
  updateProfile: schema({
    displayName: schema.string().min(1),
    bio: schema.string().optional(''),
  }),
};

export async function updateProfile(data: InferSchema<typeof schemas.updateProfile>) {
  // data is typed from the schema
}

Koze currently uses the schema as the source of truth for validated RPC input. Automatic type generation for unannotated handler parameters is not documented as part of this API yet, so use InferSchema<typeof schemas.name> when you want the handler parameter typed.

Button-triggered actions

Reference any $server/* function directly from a button. The framework spreads the call arguments into the server handler positionally, with the context object as the trailing parameter:

<script>
  import { deleteItem, toggleItem } from '$server/items';
</script>

<button type="button">Delete</button>
<button true)} type="button">Done</button>

The handler signature mirrors the call site — positional arguments come first, then the context object:

import { ActionError } from '@kuratchi/koze';

export async function deleteItem(id: number, { formData }: { formData: FormData }) {
  if (!Number.isInteger(id)) throw new ActionError('Invalid id');
  await db.items.delete(id);
}

export async function toggleItem(
  id: number,
  done: boolean,
  { formData, request }: { formData: FormData; request: Request }
) {
  await db.items.update(id, { done });
}

Handlers that don't need the context object just drop the parameter:

export async function deleteItem(id: number) {
  await db.items.delete(id);
}

Transport

Button-triggered actions share the same action endpoint as <form action={fn}>: a POST to the current route with _action (the function name) and _args (a JSON-encoded array of the call arguments). The framework bridge serializes the click-site arguments, invokes the server action, follows redirects, and refreshes SSR data after success. For interactions that need optimistic UI or custom error handling, wrap the call in a client handler and use the RPC path instead.

When to wrap in a client handler

Reach for a top-script client handler when you need something beyond a fire-and-refresh:

<script>
  import { deleteItem } from '$server/items';

  async function onDelete(id) {
    if (!confirm('Delete this item?')) return;
    await deleteItem(id);
    location.reload();
  }
</script>

<button type="button">Delete</button>

Use a client handler when you want to:

  • Prompt for confirmation before sending the request.
  • Show an inline spinner or optimistic UI update.
  • Handle errors inline without a full page re-render.

Note: When you invoke a server function directly from client JS (e.g. await deleteItem(id) inside onDelete), Koze dispatches it through the RPC path rather than the button-action path. RPC handlers receive their call arguments positionally without a trailing context object:

// Called from client JS → RPC path
export async function deleteItem(id: number) {
  await db.items.delete(id);
}

The context object is only appended when the framework invokes the handler via the action dispatch path (i.e. for <form action={fn}> submissions and <button type="button"> clicks that do NOT flow through a client handler).

See Client interactivity for the full event-handler directive, client handler registration, and SSR data hydration.

Checkbox Groups

Sync a "select all" checkbox with a group of item checkboxes:

<input type="checkbox" data-select-all="todos" />

for (const todo of todos) {
  <input type="checkbox" data-select-item="todos" value={todo.id} />
}