Taskboard 5. Form
Add a local FormStore that cleans and validates the title, runs onSubmit and adds the new task to PulseStore.
Table of contents
Stage 5. Add a validated form
- Starting state
- Complete Stage 4 so the routed Tasks screen loads its initial list through Request and Suspense.
- Applied change
- Create a FormStore for { title }, bind it with v-form and v-field, and add valid submissions directly to TaskboardStore from onSubmit.
- Observable result
- An invalid title fills validationErrors. A valid title shows isInflight during the short local onSubmit delay, sets success and appears in PulseStore. The reserved title fail makes onSubmit throw and renders submitError.
- Next step
- Continue with Stage 6 and prerender this routed application.
Follow the FormStore flow
- v-field to state
- v-field reads the named input, applies clean to trim the title and writes the canonical value to form.state.title.
- v-form to validation
- v-form intercepts submit and calls form.submit(). submit runs validate(), fills validationErrors on failure and returns before onSubmit.
- Submission state
- A valid submit clears submitError, shows isInflight during the local delay and sets success after onSubmit returns. Enter fail to make onSubmit throw and populate submitError.
Run the form
Run the same Stage 1 build and serve cycle. Submit a normal title, a one-character title and the reserved title fail to observe each FormStore state.
src/task-form.tsx
tsximport { FormStore } from "valyrian.js/forms";
import type { TaskboardStore } from "./state";
export type TaskFormStore = FormStore<{ title: string }>;
export function createTaskForm(store: TaskboardStore) {
return new FormStore({
state: { title: "" },
schema: {
type: "object",
required: ["title"],
properties: { title: { type: "string", minLength: 2 } },
},
clean: { title: (value) => String(value).trim() },
onSubmit: async ({ title }) => {
await new Promise((resolve) => setTimeout(resolve, 400));
if (title === "fail") {
throw new Error("Local FormStore submission failed");
}
store.addTask({ id: store.state.nextId, title });
},
});
}
export function TaskForm({ form }: { form: TaskFormStore }) {
return (
<form v-form={form}>
<label>
Task title
<input name="title" v-field={form} placeholder="Add a task title" />
</label>
<p v-if={form.validationErrors.title}>{form.validationErrors.title}</p>
<button type="submit" disabled={form.isInflight}>
{form.isInflight ? "Adding task..." : "Add task"}
</button>
<p v-if={form.submitError}>{String(form.submitError)}</p>
<p v-if={form.success}>Task added</p>
</form>
);
}
src/app.tsx
tsximport { TaskForm, type TaskFormStore } from "./task-form";
import type { Task, TaskboardStore } from "./state";
export function TaskboardApp({
store,
form,
}: {
store: TaskboardStore;
form: TaskFormStore;
}) {
const state = store.state;
return (
<main>
<h1>Taskboard</h1>
<button onclick={() => store.toggleDetails()}>Toggle details</button>
<p v-if={state.showDetails}>Tasks stay in one shared PulseStore.</p>
<TaskForm form={form} />
<ul v-for={state.tasks}>
{(task: Task) => <li key={task.id}>{task.title}</li>}
</ul>
</main>
);
}
src/tasks-screen.tsx
tsximport { request } from "valyrian.js/request";
import { Suspense } from "valyrian.js/suspense";
import { TaskboardApp } from "./app";
import type { TaskFormStore } from "./task-form";
import type { Task, TaskboardStore } from "./state";
async function LoadedTasks({
store,
form,
}: {
store: TaskboardStore;
form: TaskFormStore;
}) {
if (store.state.loaded === false) {
const tasks = (await request.get("/tasks.json")) as Task[];
store.replaceTasks(tasks);
}
return <TaskboardApp store={store} form={form} />;
}
export function TasksScreen({
store,
form,
}: {
store: TaskboardStore;
form: TaskFormStore;
}) {
if (store.state.loaded) {
return <TaskboardApp store={store} form={form} />;
}
return (
<Suspense
suspenseKey="taskboard:tasks"
fallback={<p>Loading tasks...</p>}
error={(error) => <p>{String(error)}</p>}
>
<LoadedTasks store={store} form={form} />
</Suspense>
);
}
src/router.tsx
tsximport { Router } from "valyrian.js/router";
import type { TaskFormStore } from "./task-form";
import type { TaskboardStore } from "./state";
import { TasksScreen } from "./tasks-screen";
export function createRouter(store: TaskboardStore, form: TaskFormStore) {
const router = new Router();
router.add("/", () => (
<>
<button onclick={() => router.go("/about")}>About</button>
<TasksScreen store={store} form={form} />
</>
));
router.add("/about", () => (
<main>
<h1>About Taskboard</h1>
<button onclick={() => router.go("/")}>Tasks</button>
</main>
));
router.catch(404, () => <h1>Taskboard page not found</h1>);
return router;
}
src/taskboard.ts
tsimport { createRouter } from "./router";
import { createTaskForm } from "./task-form";
import {
createTaskboardStore,
type TaskboardState,
} from "./state";
export function createTaskboardApp(
initialState: Partial<TaskboardState> = {},
) {
const store = createTaskboardStore(initialState);
const form = createTaskForm(store);
const router = createRouter(store, form);
return { store, form, router };
}