Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions docs/blog/2026-08-04-forms-without-a-form-library.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
---
slug: forms-without-a-form-library
title: Form Library Agnostic
description: 'Forms in bestax-bulma are plain React. Field and Control composition, auto-wrapping inputs, escape hatches, and validation your way, with useState or the validation library of your choice.'
authors: [asmith]
tags: [forms, react, bulma]
canonical_url: https://bestax.io/blog/forms-without-a-form-library
publish_to_devto: true
image: /img/forms-without-a-form-library.png
cover_image: /img/forms-without-a-form-library.png
---

![Form Library Agnostic, drawn as a pixel art RPG character creation screen: a name field with a blinking cursor, a class picker with the cursor on Mage, stat sliders for STR, DEX, and INT, and a green status row reading form libs loaded: 0, ready.](/img/forms-without-a-form-library.svg)

A form in bestax-bulma is plain React: state goes in, props come out, and nothing new sits in between. There's no bundled form library and none is coming; the library's job ends at rendering Bulma correctly, so your form state stays yours. By the end of this post you'll have a validated signup form built with nothing but `useState`, you'll see where the validation library of your choice plugs in, and you'll know where every escape hatch is.

<!-- truncate -->

This one's part of the [catch-up series](https://github.com/allxsmith/bestax/issues/384), but it's a design post, not a release recap. The components here shipped across v2 and the [v3 forms release](/blog/v3-forms-release); what follows is how they're meant to be used together.

## Field, Control, and Auto-Wrap

Bulma builds every form the same way: a `field` div handles layout and the label, a `control` div wraps exactly one input and owns its icons and loading spinner, and an optional `help` paragraph carries the message. Our [Field](/docs/api/form/field) and [Control](/docs/api/form/control) components render exactly that structure.

Writing three wrappers for every input gets old fast, so the convenience inputs handle it. [Input](/docs/api/form/input), [Select](/docs/api/form/select), and [TextArea](/docs/api/form/textarea) wrap themselves in a `Field` and `Control` automatically, which is what lets props like `label`, `iconLeftName`, and `message` exist on a single tag:

```tsx live
function AutoWrapDemo() {
const [email, setEmail] = useState('');

return (
<Input
label="Email"
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
iconLeftName="envelope"
message="One tag, and you get the label, the icon, and this help text."
id="autowrap-email"
labelProps={{ htmlFor: 'autowrap-email' }}
/>
);
}
```

To be precise about the mechanics: the wrapping isn't triggered by those props. A convenience input always wraps itself unless it detects it's already inside a `Field` or `Control`, and `label` and friends just forward into the generated wrapper. The field-level props are shared across inputs (`label`, `labelSize`, `labelProps`, `horizontal`, `message`, `messageColor`, and `fieldClassName`), with `messageColor` tinting the help text.

One accessibility note: `label` renders the `<label>` element but won't invent an `id` to point it at, so pass `id` on the input plus `labelProps={{ htmlFor: sameId }}`, the way the demos on this page do.

## Skip-If-Wrapped

Write the wrappers yourself and the inputs get out of the way. Here's the entire detection mechanism, from `FormContext.tsx`:

```tsx
const FieldContext = createContext(false);
const ControlContext = createContext(false);

export const useInsideField = () => useContext(FieldContext);
export const useInsideControl = () => useContext(ControlContext);
```

That's it. `Field` and `Control` each set their context to `true`, and an input checks both hooks before wrapping. Presence detection, not state: no values flow through this context, it only answers "am I already wrapped?" So composed layouts and convenience inputs mix freely. This is the same `Input` from the demo above, dropped into an addons layout:

```tsx live
function SearchDemo() {
const [query, setQuery] = useState('');

return (
<Field hasAddons>
<Control isExpanded>
<Input
placeholder="Search the docs"
aria-label="Search the docs"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</Control>
<Control>
<Button color="primary">Search</Button>
</Control>
</Field>
);
}
```

If `Input` had wrapped itself again, that button wouldn't sit attached to the field. And because `useInsideField` and `useInsideControl` are public exports rather than private internals, a custom input of your own can join the same protocol; the group components (`Radios`, `Checkboxes`) already ride on it.

## The Base Escape Hatch

Sometimes you want zero magic. Six components ship as raw versions with a `Base` suffix: `InputBase`, `SelectBase`, `TextAreaBase`, `DateInputBase`, `TimeInputBase`, and `DateTimeInputBase`. They render the bare Bulma element and never wrap themselves in anything:

```jsx
// Convenience: one tag, wrappers included
<Input label="Email" message="We only email for receipts." />

// Composed: you own every element
<Field label="Email" labelProps={{ htmlFor: 'composed-email' }}>
<Control>
<InputBase id="composed-email" type="email" />
</Control>
<p className="help">We only email for receipts.</p>
</Field>
```

Reach for composition when one field holds several controls, when you're building addons or grouped layouts, or when your markup needs to be exact for a snapshot test or a CSS framework migration. The [form guide](/docs/guides/library/form) shows both styles side by side.

## Validate It Yourself

Validation is the part the library deliberately leaves to you, and the pattern is short: own the value with `useState` (or `useReducer`, or whatever you prefer), compute the error yourself, and reflect it through the input's `color`, `message`, and `messageColor` props.

```tsx live
function SignupDemo() {
const [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);
const [sent, setSent] = useState(false);

const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
const error =
touched && !valid ? 'Please enter a valid email address.' : undefined;

return (
<form
onSubmit={e => {
e.preventDefault();
setTouched(true);
setSent(valid);
}}
>
<Input
label="Email"
type="email"
placeholder="you@example.com"
value={email}
onChange={e => {
setEmail(e.target.value);
setSent(false);
}}
onBlur={() => setTouched(true)}
color={error ? 'danger' : undefined}
message={error}
messageColor={error ? 'danger' : undefined}
iconLeftName="envelope"
id="signup-email"
labelProps={{ htmlFor: 'signup-email' }}
/>
<Button color="primary" type="submit" mt="3">
Sign up
</Button>
{sent && (
<Notification color="success" isLight mt="4">
Signed up. Your state, your rules, no library required.
</Notification>
)}
</form>
);
}
```

Type something that isn't an email and tab away: the border goes `danger` and the message appears, because the component reflects exactly the state you computed. One gotcha worth knowing: validation state belongs on the input, not the `Field`. `Field` has no `message` prop, and although its types accept a `color`, it renders no class for it, so setting it looks right and does nothing.

The demo's validation is one regex and one ternary. A form used to be an input and an if statement, and it turns out it still can be.

And if you'd rather keep using react-hook-form, or validate with zod or yup? Bring them. The pattern above isn't a lock-in, it's a socket: the text-style inputs are controlled components with a `value`, an `onChange`, and a string-friendly `message`, which is exactly the surface a validation library produces. Prop shapes vary at the edges (the boolean controls take `checked`, and `Autocomplete` speaks `value`, `onInput`, and `onSelect`), so wire into each component's documented props and everything here still applies. Here's the same field with its error coming from a zod schema instead of a regex:

```jsx
import { z } from 'zod';

const schema = z.object({
email: z.string().email('Please enter a valid email address.'),
});

function ZodSignup() {
const [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);

const result = schema.safeParse({ email });
const error =
touched && !result.success ? result.error.issues[0].message : undefined;

return (
<Input
label="Email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
color={error ? 'danger' : undefined}
message={error}
messageColor={error ? 'danger' : undefined}
/>
);
}
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

react-hook-form works the same way: hand its field state to `value` and `onChange`, or spread `register` onto the input, and pass `formState.errors.email?.message` to `message`. Same three props, whatever validation library you choose. bestax-bulma doesn't prescribe one, and it doesn't make you carry one either.

## The Full Input Set

None of this would matter if the inputs stopped at `<input type="text">`. The core set covers stock Bulma, and the extended set goes well past it:

| Component | What you get |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| [**Input**](/docs/api/form/input), [**TextArea**](/docs/api/form/textarea), [**Select**](/docs/api/form/select), [**File**](/docs/api/form/file) | The core text, select, and file controls |
| [**Checkbox**](/docs/api/form/checkbox) and [**Radio**](/docs/api/form/radio), plus [**Checkboxes**](/docs/api/form/checkboxes) and [**Radios**](/docs/api/form/radios) group containers | Boolean and single-choice controls, themed variants |
| [**Switch**](/docs/api/form/switch) | A checkbox styled as a toggle |
| [**Slider**](/docs/api/form/slider) | Range slider, single or dual thumb |
| [**Numberinput**](/docs/api/form/numberinput) | Number input with stepper buttons |
| [**Rate**](/docs/api/form/rate) | Star ratings without hand-rolled stars |
| [**Autocomplete**](/docs/api/form/autocomplete) | Filterable suggestion input |
| [**Taginput**](/docs/api/form/taginput) | Multi-tag input with suggestions |
| [**DateInput**](/docs/api/form/datetime/dateinput), [**TimeInput**](/docs/api/form/datetime/timeinput), [**DateTimeInput**](/docs/api/form/datetime/datetimeinput) | Date and time pickers on native `Date` and `Intl` |

The wrap behavior stays consistent across the table: most of the set generates a full `Field` and `Control`, the inputs that manage richer inner markup (`Autocomplete`, `Taginput`, `File`, and `Numberinput`) generate just the `Field`, and the small controls (`Checkbox`, `Radio`, `Switch`) plus every `Base` component never wrap at all. Every input that wraps itself also takes the `label` and `message` treatment from the top of this post, and state stays plain React throughout: a value in, a change handler out, whether that value is a string, a number, a tag list, or a `Date`.

The date and time pickers got their own deep dive in the [v3 forms release](/blog/v3-forms-release), so I won't rerun it here. The short version: popover calendar, wheel spinner, segmented keyboard entry, and zero date libraries in your bundle.

## Documentation

- [Form components guide](/docs/guides/library/form): descriptions and live examples for every input
- API references: [Field](/docs/api/form/field), [Control](/docs/api/form/control), and [Input](/docs/api/form/input) are the best starting points
- The [bestax-form skill](/docs/skills/form) teaches AI agents this exact composition and validation pattern
- The [blog revival tracker](https://github.com/allxsmith/bestax/issues/384) has the rest of the catch-up plan
25 changes: 18 additions & 7 deletions docs/blog/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@ version bump) and the intro of the first State of React edition: candid, plain,
- **Verify:** `pnpm format`, then `pnpm exec turbo run build --filter=@allxsmith/bestax-docs`,
then `pnpm format:check`. Commits and PR titles use the non-releasing `docs` type.

## Syndication (dev.to)
## Syndication (dev.to and Medium)

Opt-in per post: `publish_to_devto: true` plus a `cover_image` in the frontmatter
(`plugins/devto-preprocessor.js` skips posts without the flag). Publishing to dev.to itself
stays a manual act — the build only generates the files.

- **A syndicated post must ship a hero/cover image.** Any post with `publish_to_devto: true`
(or headed to Medium) follows the "Cover images" section below in full: SVG + PNG assets,
`image:`/`cover_image:` frontmatter, and the visible markdown banner at the top of the
body. A coverless post renders bare on both platforms.
- Each flagged post gets a copy in `build/.devto-publish/` with production URLs: markdown
images and root-relative `/docs/` + `/blog/` links (reference-style definitions included)
are rewritten to `https://bestax.io/...`. Fenced code blocks are never touched.
Expand All @@ -76,14 +80,22 @@ stays a manual act — the build only generates the files.
markdown image (`![…](/img/…)`), not a JSX `<img>` (which would reach dev.to unrewritten
and broken).

Medium is manual end to end: there is no Medium plugin. Publish on bestax.io first, then use
Medium's import-a-story flow on the live `https://bestax.io/blog/<slug>` URL so Medium records
the canonical source; the cover PNG doubles as the story's feature image.

## Cover images (any post)

Any post can ship a cover, not just State of React editions; _The Floor Is React 18_
(2026-08-03) is the reference example.

- **Assets:** `docs/static/img/<slug>.svg` (hand-authored, 1200×630) plus a PNG raster at
the same stem — the State of React runbook's "Cover image" section below has the
Playwright recipe.
- **Assets:** `docs/static/img/<slug>.svg` (hand-authored, 1200×630, with a full-bleed
background rect and explicit `width`/`height` attributes; the script refuses to rasterize
without them) plus a PNG raster at the same stem. Generate the PNG with
`pnpm --filter @allxsmith/bestax-docs rasterize:cover static/img/<slug>.svg`
(`scripts/rasterize-cover.mjs` screenshots the SVG in headless Chromium at exactly
1200×630 and writes the sibling `.png`). Missing Chromium? Once:
`pnpm --filter @allxsmith/bestax-docs exec playwright install chromium`.
- **Frontmatter:** `image:` and `cover_image:` both point at the **PNG** (rooted `/img/...`
path); `og:image` and dev.to need a raster.
- **Body:** the visible banner at the very top renders the **SVG**, crisp at any width.
Expand Down Expand Up @@ -148,9 +160,8 @@ Each edition ships a synthwave/EDM cover (an homage to the "A State of Trance" r
- **Source SVG:** `docs/static/img/state-of-react/{YYYY-MM}.svg` — for a new edition, copy the
previous month's and update the month text and the `EP.` number.
- **Rasterize** it to `docs/static/img/state-of-react/{YYYY-MM}.png` at **1200×630** — SVG does
not work as an `og:image` / dev.to `cover_image`, so a raster is required. Load the SVG in a
headless browser at 1200×630 and screenshot it (this repo used Playwright + the pre-installed
Chromium at `/opt/pw-browsers`).
not work as an `og:image` / dev.to `cover_image`, so a raster is required. Run
`pnpm --filter @allxsmith/bestax-docs rasterize:cover static/img/state-of-react/{YYYY-MM}.svg`.
- **Frontmatter:** point both `image:` and `cover_image:` at the `.png` (rooted `/img/...` path).
- **Visible banner:** at the very top of the post body, render the SVG full-width:
`<img className="sor-cover" src="/img/state-of-react/{YYYY-MM}.svg" alt="…" />`.
Expand Down
2 changes: 2 additions & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"start": "docusaurus start",
"build": "docusaurus build && node scripts/strip-generated-markers.mjs",
"test": "node --test \"scripts/*.test.mjs\"",
"rasterize:cover": "node scripts/rasterize-cover.mjs",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
Expand Down Expand Up @@ -41,6 +42,7 @@
"copy-webpack-plugin": "^11.0.0",
"docusaurus-plugin-llms": "^0.5.1",
"gray-matter": "^4.0.3",
"playwright": "^1.62.0",
"raw-loader": "^4.0.2"
},
"browserslist": {
Expand Down
Loading
Loading