@k8ordo/form

Search and filter forms

A search or filter form works best as a GET form: the conditions land in the URL, so the link can be shared and the back button returns to the previous ones. With @k8ordo/state, one schema describes both the form’s constraints and the URL state.

On this page

Share one schema with the URL state

Write the url schema of a definePageState from @k8ordo/state, and pass the same schema to formFields. The form’s constraints come from it too.

list-state.ts
export const listState = definePageState('product-list', {
  url: z.object({
    q: z.string().default(''),
    inStock: z.stringbool().default(false),
  }),
});
page.tsx
const filterFields = formFields(listState.url);

export default function ProductsPage() {
  return <Filters fields={filterFields} />;
}
Information

Note

useAppState reads the URL with this schema in the browser, so it ends up in the client bundle. Writing it with zod/mini keeps that small.

Call useForm without a state

There is no Server Action to receive the submission, so useForm takes the fields alone. Each field starts from the current state, read with useAppState and handed over as defaultValue.

filters.tsx
const form = useForm(fields);
const [current] = useAppState(listState);
const q = form.field('q');
const inStock = form.field('inStock');

<form {...form.props} method="get">
  <input {...q.input} defaultValue={current.q} type="search" />
  <input {...inStock.input} defaultChecked={current.inStock} />
  <button type="submit">Filter</button>
</form>

Without a state it is still checked on submit, so a filter that breaks its schema never reaches the URL.

Information

Note

The DOM keeps the values, so going back changes the URL but not what the fields show. To make the fields follow the URL too, rebuild the form whenever it changes, with <form key={search}>.

Playground

Try a filter form

The same demo as on the @k8ordo/form landing. Submitting it rewrites this page’s URL.

URL
No query (all defaults)
state
{"q":"","min":0,"inStock":false}

Try it

  1. Type a keyword and press “Filter”. The URL gains ?q=, and the page does not reload.
  2. Check “In stock only” and submit. The URL gains inStock=true.
  3. Press the browser’s back button. The URL and the state line return to the previous conditions; the fields keep what you entered.

The submission is not a page load

Under @k8ordo/router, a GET submission to the same pathname is taken by the router and treated as a state update, not a page load. Scroll position and focus stay where they were.

Without JavaScript it is an ordinary GET form, and it arrives at the same URL.

Write booleans with z.stringbool()

A boolean in the URL is a z.stringbool(). Its checkbox submits the same spelling of true that update() writes, so the URL the form lands on and the one update() writes agree.

Warning

Pitfall

A spread value does not reach @k8ordo/ui’s Checkbox. Draw a z.stringbool() field as a plain <input> with input spread onto it.

Read the conditions on the server

Under @k8ordo/server, a page that exports the url schema as search receives the parsed conditions and renders with them, so the results are in the page before JavaScript arrives.

routes/products/page.tsx
export const search = listState.url;

export default async function ProductsPage({
  search,
}: PageProps<'/products'>) {
  const products = await findProducts(search);
  return <ProductList products={products} />;
}

Where the server cannot read them, as under @k8ordo/static, the server render shows the defaults and the submitted conditions appear after hydration.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2