brumate

Forms and actions

A form can post to a server function. It works before any script loads, and with scripts turned off. Once the loader runs, the same form posts without a full page load.

A form that posts to a server function

Set the action of the form to a server function. The function gets the FormData of the form.

<script>
  async function subscribe(form: FormData) {
    'use server';
    await db.subscribers.insert(String(form.get('email')));
  }
</script>

<form action=subscribe>
  <label>
    Email
    <input name="email" type="email" required>
  </label>
  <button>Subscribe</button>
</form>

What happens when the form is sent:

Showing the result with $action

A form often needs the answer of its function, such as an error to show next to a field. $action(fn) holds the latest result of a server function that a form of this component posts to.

App.brmEdit the code to run it again

The sample runs when it comes into view.

Validating a form

parseForm(schema, form) from brumate/server turns the fields of a form into an object, and checks it with a Standard Schema. It returns { value } or { issues }, and does not throw. fieldErrors(issues) groups the messages by field, for $action to show:

App.brmEdit the code to run it again

The sample runs when it comes into view.

How the fields become an object:

What fieldErrors returns:

parse(schema, value) throws instead, with status 400. Use it for values that a correct page always sends right. Without scripts, the browser shows its message as text. See Validating arguments.

Forms with createForm

createForm from brumate/forms gives the attributes of a form and of its fields: names, IDs, the values that were posted, errors and their ARIA attributes, and buttons that add and remove the items of a list. Its server function reads the form with parseSubmission from brumate/server. The form works without scripts, and with scripts it posts without loading the page.

<script>
  import { createForm } from 'brumate/forms';
  import { parseSubmission } from 'brumate/server';
  import { Order } from '../schema.ts';

  async function save(form: FormData) {
    'use server';
    const submission = await parseSubmission(Order, form);
    if (submission.status !== 'success') {
      // The errors, and the values as posted, so the fields show what was typed.
      return submission.reply();
    }
    if (await db.orders.exists(submission.value.email)) {
      return submission.reply({ formErrors: ['That email has an order already'] });
    }
    await db.orders.save(submission.value);
    return submission.reply({ reset: true });
  }

  const { attrs, fields, errors, defaultSubmit } = createForm(save, Order, {
    defaultValue: { email: '', items: [{ name: '' }] },
  });
</script>

<form action=save {...attrs}>
  <button {...defaultSubmit}></button>

  <label for=(fields.email.id)>Email</label>
  <input {...fields.email.attrs} type="email" required>
  #if (fields.email.errors) {
    <p {...fields.email.errorAttrs}>{fields.email.errors[0]}</p>
  }

  #for (item of fields.items.list by item.key) {
    <input {...item.fields.name.attrs} aria-label="Item">
    <button {...item.remove}>Remove</button>
  }
  <button {...fields.items.insert}>Add an item</button>

  #if (errors) {
    <p role="alert">{errors[0]}</p>
  }
  <button>Save</button>
</form>

The server function

parseSubmission(schema, form, options?) reads the fields as parseForm does, and validates them with the schema. It does not throw. Its status is:

submission.reply(options?) is what the function returns. It holds the values as posted, the errors of each field by field name, such as items[0].name, and the errors of the whole form. Its options:

Files are never sent back, because a file input cannot be filled again. List other fields that must not come back, such as a password, in options.hide:

const submission = await parseSubmission(SignUp, form, { hide: ['password'] });

Fields

fields follows the default value. Each field has:

An object has its fields in fields, such as fields.address.fields.city. A list has:

defaultValue names every field that the form shows. A list of objects has one item in it, and new items take its fields.

Buttons that change a list

The buttons of a list are submit buttons with the name __intent. With scripts, a button changes the list in the browser at once. Without scripts, it posts the form, and the server applies the change to the posted values:

Enter in a field presses the first submit button of the form. defaultSubmit is a hidden submit button. Put it first, so Enter saves the form instead of pressing "Remove".

Validating in the browser

attrs holds an onFocusout and an onInput handler. They validate the fields with the schema in the browser, so errors show before a submit:

The schema runs in the browser, so it must not import server code. A check that needs the server, such as a database, goes in the server function, with reply({ formErrors }) or reply({ fieldErrors }).

With and without scripts

Redirects after a post

redirect(location) from brumate/server sends the browser to another page, with or without scripts:

import { redirect } from 'brumate/server';

async function createPost(form: FormData) {
  'use server';
  const id = await db.posts.insert(String(form.get('title')));
  redirect(`/posts/${id}`);
}

A function that throws a ServerError answers with its status and message. Without scripts, the browser shows the message as text, because there is no page to show it in. Return errors as values with $action to show them on the page.

Files

A form with a file input sends its files to the function as File values in the FormData:

<script>
  async function upload(form: FormData) {
    'use server';
    const file = form.get('avatar') as File;
    await storage.put(file.name, await file.arrayBuffer());
  }
</script>

<form action=upload enctype="multipart/form-data">
  <label>
    Avatar
    <input type="file" name="avatar">
  </label>
  <button>Upload</button>
</form>

Set enctype="multipart/form-data", so the browser sends the files when scripts are off. A handler can also pass a file to a server function directly. See Server functions.

Forms posted twice

A form can be posted twice: a double click before the page loads, or a post again after a reload or the back button. For a function that must not run twice, such as one that places an order, wrap it in idempotent from brumate/server:

// orders.ts
'use server';

import { idempotent } from 'brumate/server';

export const placeOrder = idempotent(async (form: FormData) => {
  await db.orders.insert(String(form.get('item')));
  redirect('/orders');
});
<script>
  import { placeOrder } from '../orders.ts';
</script>

<form action=placeOrder>…</form>

Refreshing other data

After a form changes data, call event.refresh(fn) in its function with the server function that reads the data. Memos that call it load again. See Loading again. Without scripts, the page renders again, so its memos are new anyway.

Showing a guess while it posts

$optimistic(source) follows a memo, and handlers can write it. The page shows the new value at once, and the server sends the real one after:

<script>
  import { $action, $memo, $optimistic } from 'brumate/signals';
  import { getEvent } from 'brumate/server';

  async function list() {
    'use server';
    return db.names();
  }

  async function sign(form: FormData) {
    'use server';
    const event = getEvent();
    const name = String(form.get('name') ?? '');
    if (!name) {
      return { error: 'Enter a name' };
    }
    await db.add(name);
    event.refresh(list);
    return { error: '' };
  }

  const signed = $action(sign);
  const names = $memo(list());
  let shown = $optimistic(names);

  function guess(event: SubmitEvent) {
    const name = new FormData(event.currentTarget as HTMLFormElement).get('name');
    shown = [...shown, String(name)];
  }
</script>

<form action=sign onSubmit=guess>
  <input name="name" aria-label="Name">
  <button>Sign</button>
</form>
#if (signed?.error) {
  <p>{signed.error}</p>
}
<ul>
  #for (name of shown by name) {
    <li>{name}</li>
  }
</ul>