# Actions

> Form actions, ActionError, redirects, and RPC form submissions

Package: Koze
Canonical: https://kuratchi.dev/docs/koze/actions
Markdown: https://kuratchi.dev/docs/koze/actions.md

## Form actions

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

```html
<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:

```ts
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](#button-triggered-actions) `args` carries
the positional call arguments and `ctx` stays as the trailing parameter.

If you want a reusable annotation, import `ActionContext`:

```ts
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:

```html
<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:

```ts
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:

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

Each hook receives:

```ts
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:

```html
<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:

```html
<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:

```ts
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:

```ts
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:

```html
<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()`:

```html
<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:

```ts
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.

```ts
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:

```html
<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:

```ts
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](#form-actions) as the trailing parameter:

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

<button onclick={deleteItem(item.id)} type="button">Delete</button>
<button onclick={toggleItem(item.id, true)} type="button">Done</button>
```

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

```ts
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:

```ts
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:

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

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

<button onclick={onDelete(item.id)} 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:

  ```ts
  // 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 onclick={fn(…)} type="button">` clicks that
  do NOT flow through a client handler).

See [Client interactivity](/docs/koze/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:

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

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