Tutorial: 6. Forms and actions

Tutorial

6. Forms and actions

Changes still don't survive a reload. In this step, adding a book and marking one read become form submissions handled on the server by actions: functions in +page.server.ts that a form posts to.

Actions

Replace src/routes/+page.server.ts:

src/routes/+page.server.ts
import { fail, type LoadContext } from "@sigil-dev/grimoire";
import { addBook, listBooks, setRead } from "../lib/server/db";

export function load() {
	return { books: listBooks() };
}

export async function add({ request }: LoadContext) {
	const form = await request.formData();
	const title = String(form.get("title") ?? "").trim();
	const author = String(form.get("author") ?? "").trim();
	if (!title) {
		return fail(400, { author, problem: "A book needs a title." });
	}
	addBook(title, author);
}

export async function toggle({ request }: LoadContext) {
	const form = await request.formData();
	setRead(Number(form.get("id")), form.get("read") === "1");
}

Exported functions other than load are actions, so keep helpers unexported. A form picks an action with its action attribute: action="?/add" posts to the current page and runs add. After an action returns, the browser is sent back to the page, and load runs again with the new data.

fail(status, data) means the submission was rejected: the page is shown again with that status, and data goes back to it so it can say what went wrong.

The forms

Replace src/lib/BookRow.tsx. The checkbox becomes a button in a small form:

src/lib/BookRow.tsx
import { enhance } from "@sigil-dev/grimoire/client";
import type { Book } from "./books";

export const BookRow = ({ book }: { book: Book }) => (
	<li class={book.read ? "book read" : "book"}>
		<a class="title" href={`/books/${book.id}`}>
			{book.title}
		</a>
		<span class="author">{book.author}</span>
		<form method="POST" action="?/toggle" use={enhance}>
			<input type="hidden" name="id" value={book.id} />
			<button name="read" value={book.read ? "0" : "1"}>
				{book.read ? "Mark unread" : "Mark read"}
			</button>
		</form>
	</li>
);

<style>
	.book {
		display: flex;
		gap: 1rem;
		align-items: baseline;
		padding: 0.5rem 0;
		border-bottom: 1px solid #ddd;
	}
	.author {
		flex: 1;
		color: #666;
	}
	.read .title {
		text-decoration: line-through;
		color: #888;
	}
</style>

And replace src/routes/index.tsx:

src/routes/index.tsx
import { enhance } from "@sigil-dev/grimoire/client";
import { BookRow } from "../lib/BookRow";
import type { Book } from "../lib/books";

interface Props {
	data: { books: Book[] };
	form?: { problem: string; author: string };
}

const Index = ({ data, form }: Props) => {
	// `form` is set when a submission without JavaScript failed
	let problem = $state(form?.problem ?? "");
	let unread = $derived(data.books.filter((b) => !b.read).length);

	return (
		<>
			<h1>Reading list</h1>
			<form
				method="POST"
				action="?/add"
				use={[
					enhance,
					{
						onSuccess: () => (problem = ""),
						onFail: (result: { problem?: string }) => (problem = result.problem ?? ""),
					},
				]}
			>
				<input name="title" placeholder="Title" />
				<input name="author" placeholder="Author" value={form?.author ?? ""} />
				<button>Add</button>
			</form>
			{problem && <p role="alert">{problem}</p>}
			<ul>
				{data.books.map((book) => (
					<BookRow key={book.id} book={book} />
				))}
			</ul>
			<p>{unread} still to read.</p>
		</>
	);
};

export default Index;

The page no longer keeps its own copy of the books. It renders data.books, and after each action the new data replaces the old.

Add a book, mark one read, reload: it's all still there. Submit the form with an empty title and the problem appears under it.

Two ways to submit

These are ordinary HTML forms, so they work before any JavaScript has loaded, or with JavaScript turned off. The browser posts the form, the action runs, and the server answers with a redirect back to the page. If the action returned fail(…), the server renders the page again with the failure data as its form prop, which is why the page reads form?.problem.

use={enhance} upgrades a form once the page has loaded. Submitting then happens in the background with fetch, and the router re-runs the page's load, so the list updates in place with no reload and no lost scroll position. onFail receives what fail() returned, and onSuccess runs when the action succeeded. By default an enhanced form resets after a successful submission, which is what empties the title box.

use={…} attaches a function to an element when it's created. enhance is one; you can write your own (Directives and transitions).

Security

Forms that post are protected from cross-site request forgery automatically. The server renders a hidden token into every method="POST" form and rejects posts that don't carry it. Actions never run for a GET request: visiting /?/add renders the page and nothing else.

Next: shipping it.

Edit this page