# Day 4: Forms, Native Controls, Server Validation, and Local Previews

**Onboarding track:** Alpine.js + htmx, co-equal architecture  
**Verified baseline:** Alpine.js 3.15.12 and htmx 2.0.10 (stable)  
**Verification date:** 2026-07-19  
**Compatibility boundary:** htmx 4.0.0-beta5 is a prerelease line and is not the syntax baseline for these lessons.

## Purpose

Combine native forms, Alpine reactivity, htmx submission, and server-rendered validation without duplicating form architecture or business rules.

## How to use this document

Budget a working day. Read the lessons in order, type the examples instead of copying them blindly, perform every mini-task, and finish the exit exercise without looking at the answer notes. The examples use a fictional server-rendered operations application, but the ownership rules are framework-neutral.

A successful day ends with three artifacts:

1. working markup or a small runnable page;
2. written ownership notes for each state value and DOM region;
3. answers to the comprehension check.

## Learning outcomes

You will be able to:

- use named native controls as the shared Alpine–htmx bridge;
- build conditional fields and provisional previews with Alpine;
- submit and replace forms through htmx;
- return accessible server-rendered validation;
- preserve values, focus, and a valid non-enhanced path;
- distinguish local hints from trusted validation.

## 1. Ownership map for a mixed form

| Concern | Owner |
| --- | --- |
| Labels, controls, names, grouping | HTML |
| Immediate native constraints | Browser |
| Local disclosure, hints, and previews | Alpine |
| Submission, target, swap, request state | htmx |
| Authorization and trusted validation | Server |
| Error and success markup | Server templates |
| Durable result | Server/database |
| Focus after replacement | returned HTML, Alpine, or narrow integration code |

The strongest rule is:

> A value the server needs should live in a successful native control or an explicitly declared request parameter.

Do not maintain a hidden Alpine-only form model that can diverge from the controls.

## 2. Baseline form

```html
<form action="/account" method="post">
    <label>
        Display name
        <input name="display_name" required>
    </label>
    <button type="submit">Save</button>
</form>
```

Build and test this path first. Then enhance it.

## 3. Add Alpine for local conditional presentation

```html
<form
    id="account-form"
    x-data="{ accountType: 'personal' }"
    action="/account"
    method="post"
>
    <label>
        Account type
        <select name="account_type" x-model="accountType">
            <option value="personal">Personal</option>
            <option value="business">Business</option>
        </select>
    </label>

    <fieldset x-cloak x-show="accountType === 'business'">
        <legend>Business details</legend>
        <label>
            Company name
            <input name="company_name">
        </label>
    </fieldset>

    <button type="submit">Save</button>
</form>
```

The `<select>` is both:

- the real submitted value; and
- the reactive source for Alpine.

Do not create a second `accountType` in a global store.

### Hidden controls and submission

`x-show` hides visually with CSS; the controls remain in the form and may still submit. Decide the server contract:

- accept and ignore irrelevant fields;
- disable the fieldset when hidden;
- or use `x-if` when the subtree should not exist.

A deliberate pattern:

```html
<fieldset
    x-cloak
    x-show="accountType === 'business'"
    :disabled="accountType !== 'business'"
>
```

A disabled fieldset’s controls are not successful controls. The server must still validate the account-type rules; the client behavior is only usability.

## 4. Add htmx submission and complete-form replacement

```html
<form
    id="account-form"
    x-data="{ accountType: 'personal' }"
    action="/account"
    method="post"
    hx-post="/account"
    hx-target="#account-form"
    hx-swap="outerHTML"
    hx-indicator="#account-saving"
    hx-disabled-elt="find fieldset, find button[type='submit']"
>
    <fieldset>
        <label>
            Account type
            <select name="account_type" x-model="accountType">
                <option value="personal">Personal</option>
                <option value="business">Business</option>
            </select>
        </label>

        <div x-cloak x-show="accountType === 'business'">
            <label>
                Company name
                <input name="company_name">
            </label>
        </div>
    </fieldset>

    <button type="submit">Save</button>
    <span id="account-saving" class="htmx-indicator" role="status">
        Saving…
    </span>
</form>
```

Why the disabling selector uses a fieldset:

- htmx 2’s `find <selector>` resolves a matching descendant, not “every descendant of this type” as a casual reading may suggest;
- disabling a containing fieldset reliably disables its descendant form controls;
- the submit button is listed separately if it sits outside the fieldset.

Use explicit IDs or a disabled fieldset rather than assuming `find input, find select, find button` disables every control.

## 5. Server response contract

### Success

Return the authoritative resulting component:

```html
<section id="account-form">
    <p role="status">Account saved.</p>
    <dl>
        <dt>Type</dt>
        <dd>Business</dd>
        <dt>Company</dt>
        <dd>Analytical Engines Ltd.</dd>
    </dl>
</section>
```

### Validation failure

Return the complete form with submitted values and accessible errors:

```html
<form
    id="account-form"
    x-data="{ accountType: 'business' }"
    action="/account"
    method="post"
    hx-post="/account"
    hx-target="#account-form"
    hx-swap="outerHTML"
>
    <div id="error-summary" role="alert" tabindex="-1">
        <p>Correct the following error:</p>
        <a href="#company-name">Company name is required.</a>
    </div>

    <label>
        Account type
        <select name="account_type" x-model="accountType">
            <option value="personal">Personal</option>
            <option value="business" selected>Business</option>
        </select>
    </label>

    <label>
        Company name
        <input
            id="company-name"
            name="company_name"
            value=""
            aria-invalid="true"
            aria-describedby="company-name-error"
        >
    </label>
    <p id="company-name-error">Company name is required.</p>

    <button type="submit">Save</button>
</form>
```

The server decides:

- accepted values;
- normalization;
- authorization;
- validation;
- error text;
- field relationships;
- whether the operation commits.

Alpine may provide early hints, but it should not duplicate the server’s complete validation schema.

## 6. Status-code policy in htmx 2.x

By default, htmx 2.x does not swap `4xx` or `5xx` responses. Choose a project policy.

### Simple validation policy

Return the form with status `200` for expected validation failures. Reserve error status codes for failures handled by a different response policy.

### Configured response policy

Configure htmx to swap selected error responses, or use a response-targets strategy, and test every status.

Whichever policy is chosen, document it. Do not allow individual features to guess.

htmx 4 changes the default and swaps `4xx`/`5xx`, which is one reason these lessons do not claim htmx 4 compatibility.

## 7. Provisional calculations

```html
<form
    x-data="{ quantity: 1, unitPrice: 18 }"
    action="/cart/lines/42"
    method="post"
    hx-post="/cart/lines/42"
    hx-target="#cart-line-42"
    hx-swap="outerHTML"
    hx-sync="this:replace"
>
    <label>
        Quantity
        <input
            type="number"
            name="quantity"
            min="1"
            x-model.number="quantity"
        >
    </label>

    <output>
        Provisional subtotal:
        $<span x-text="(quantity * unitPrice).toFixed(2)"></span>
    </output>

    <button type="submit">Update</button>
</form>
```

Alpine gives immediate feedback. The server still owns:

- current price;
- accepted quantity;
- inventory;
- discounts;
- tax;
- final total.

Label the preview “provisional,” “estimate,” or equivalent when users could mistake it for a committed amount.

## 8. Inline validation versus final validation

An input may request a server hint:

```html
<form
    action="/users"
    method="post"
    hx-post="/users"
    hx-target="#user-form"
    hx-swap="outerHTML"
    hx-sync="this:replace"
>
    <label>
        Username
        <input
            name="username"
            required
            hx-post="/users/validate-username"
            hx-trigger="change"
            hx-target="#username-hint"
            hx-sync="closest form:abort"
        >
    </label>
    <div id="username-hint" aria-live="polite"></div>
    <button type="submit">Create user</button>
</form>
```

The inline endpoint offers a hint. Final submission revalidates everything. The synchronization policy prevents an obsolete validation request from winning over submission.

## 9. File upload pattern

```html
<form
    action="/documents"
    method="post"
    enctype="multipart/form-data"
    hx-post="/documents"
    hx-encoding="multipart/form-data"
    hx-target="#upload-result"
>
    <label>
        Document
        <input type="file" name="document" required>
    </label>
    <button type="submit">Upload</button>
</form>

<div id="upload-result" aria-live="polite"></div>
```

The server owns:

- file type and size rules;
- malware scanning;
- storage;
- durable acceptance;
- authorization.

A client-side filename or preview is not evidence of acceptance.

## 10. Focus and announcement after validation

Choose one project policy:

1. render `autofocus` on the first invalid field;
2. focus an error summary after the swap;
3. emit a semantic after-swap event that a stable Alpine shell handles;
4. use a narrow `htmx:afterSettle` integration listener.

Avoid two competing focus systems.

A narrow htmx 2 integration:

```html
<script>
document.body.addEventListener('htmx:afterSettle', (event) => {
    const target = event.detail.target
    if (!target?.matches?.('#account-form')) return

    target.querySelector('[data-focus-after-swap]')?.focus()
})
</script>
```

Test with keyboard and screen reader. Do not attach an undifferentiated global listener that guesses business workflow from arbitrary markup.

## 11. Form SOP

For every mixed form:

1. build the complete non-enhanced form;
2. name every server-required control;
3. add native constraints for usability;
4. add Alpine only for local conditional UI or previews;
5. add htmx request, target, and swap;
6. define request indicator and disabling;
7. define race behavior;
8. return complete server-rendered success or error HTML;
9. preserve submitted values on error;
10. define focus and announcement;
11. test no-JavaScript submission;
12. test duplicate activation, slow responses, expired sessions, and validation failures.

## 12. Day lab: account onboarding form

Build a form with:

- personal/business account type;
- conditionally active company fields;
- a character counter for a description;
- a provisional monthly price estimate;
- a server username availability hint;
- final server validation;
- complete root replacement;
- an accessible error summary;
- a saving indicator and disabled fieldset.

Write a server response specimen for:

1. success;
2. validation failure;
3. authorization failure;
4. unexpected server error.

## Mini-tasks

### Task A: successful controls

Explain whether hidden, disabled, unnamed, and unchecked controls submit.

### Task B: status policy

Choose and document a validation status-code policy for an htmx 2 project.

### Task C: eliminate client schema duplication

Replace an Alpine object containing every server validation rule with native hints plus server-rendered validation.

### Task D: provisional truth

Add labeling that prevents a locally calculated total from being mistaken for a committed price.

## Comprehension check

1. Why is a native input the best Alpine–htmx bridge?
2. Who owns trusted validation?
3. What must a validation response preserve?
4. Why might `x-show` submit hidden controls?
5. When should a fieldset be disabled?
6. Why is `find input` not a reliable shorthand for disabling all inputs?
7. What is the difference between an inline availability hint and final validation?
8. What htmx 2 status behavior must be considered?
9. Why should success return authoritative HTML rather than `{ "ok": true }`?
10. Who owns focus after a form swap?

## Cheat sheet

```text
VALUE SERVER NEEDS        -> named control
LOCAL PREVIEW             -> Alpine bound to that control
SUBMISSION                -> htmx
FINAL VALIDATION          -> server
ERROR MARKUP              -> server template
REQUEST BUSY              -> htmx + CSS
FORM ROOT REPLACED        -> Alpine state resets
STABLE OVERLAY SHELL      -> target inner form only
```

## Exit exercise

Create an invoice adjustment form where Alpine previews an adjustment, htmx submits it, and the server returns either a canonical invoice line or a complete error form. Include an explicit conflict response for a price that changed since the page loaded.

## Exit criteria

You pass when the server can reconstruct and validate the operation from the request without reading Alpine state, and every success or failure produces usable HTML.

## Official reference shelf

Use these as the authority when a project version or behavior is uncertain:

- Alpine.js documentation: https://alpinejs.dev/
- Alpine.js directives: https://alpinejs.dev/directives/
- Alpine.js `Alpine.data()` lifecycle and `destroy()`: https://alpinejs.dev/globals/alpine-data
- htmx 2.x documentation: https://htmx.org/docs/
- htmx 2.x reference: https://htmx.org/reference/
- htmx 2.x events: https://htmx.org/events/
- htmx 4 migration guide: https://four.htmx.org/migration-guide-htmx-4/

> Version note: These exercises use htmx 2.x event names such as `htmx:afterSwap` and `htmx:afterSettle`. htmx 4 changes event naming and several defaults. Do not mechanically port the examples without the migration guide.
