# Day 7: Semantic Events and Cross-Boundary Contracts

**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

Connect Alpine-owned local interactions, htmx-owned requests, and server outcomes without direct state reaching, hidden watchers, or event soup.

## 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:

- dispatch semantic browser events from Alpine;
- trigger htmx requests from custom events;
- emit semantic events from server responses;
- select immediate, after-swap, or after-settle timing;
- distinguish commands from facts;
- document event producers, listeners, payloads, failure behavior, and version-sensitive mechanics.

## 1. Why events are the preferred bridge

A mixed component often needs cooperation:

```text
Alpine local action
    -> semantic browser event
        -> htmx request
            -> server response
                -> htmx swap or response event
                    -> Alpine local reaction
```

Events preserve independent ownership. Alpine does not call private htmx internals, and htmx does not mutate Alpine state directly.

## 2. Event naming

Good names express domain meaning:

- `delete-confirmed`;
- `cart-updated`;
- `profile-saved`;
- `filters-committed`;
- `dialog-close-requested`;
- `job-completed`.

Poor names expose implementation:

- `run-htmx-button`;
- `set-alpine-open`;
- `swap-right-panel`;
- `update-store-value`.

Use past tense for facts (`record-saved`) and imperative/intent language for commands (`record-delete-requested`, `delete-confirmed`).

## 3. Alpine command to htmx request

```html
<div x-data="{ confirmation: '' }">
    <label>
        Type DELETE
        <input x-model="confirmation">
    </label>

    <button
        type="button"
        :disabled="confirmation !== 'DELETE'"
        @click="$dispatch('delete-confirmed', { id: 42 })"
    >
        Confirm deletion
    </button>
</div>

<button
    hidden
    type="button"
    hx-delete="/records/42"
    hx-trigger="delete-confirmed from:window"
    hx-target="#record-42"
    hx-swap="delete"
></button>
```

Ownership:

- Alpine: custom confirmation interaction;
- htmx: request trigger and deletion request;
- server: authorization and actual deletion;
- target: record component;
- event: a command that the local confirmation completed.

The `id` in the event detail is documentation in this fixed-route example; htmx is not automatically substituting that value into the URL. For dynamic IDs, use a route-specific listener element, real control, explicit request value, or a small integration handler. Do not imply payload magic that does not exist.

## 4. Server fact to Alpine reaction

The server can return a response header:

```http
HX-Trigger-After-Swap: {"profile-saved":{"id":42}}
```

Stable Alpine shell:

```html
<section
    x-data="{ open: true }"
    @profile-saved.window="open = false"
>
    <!-- modal or drawer shell -->
</section>
```

The event says what happened. Alpine decides how the local interface reacts.

Response trigger headers:

- `HX-Trigger`: when the response is received;
- `HX-Trigger-After-Swap`: after new markup is installed;
- `HX-Trigger-After-Settle`: after settling work.

Response headers are not processed on ordinary HTTP redirects. If a workflow depends on an event, return an htmx-processed response or use an explicit redirect policy.

## 5. Timing decision

### Immediate

Use when the event is independent of installed DOM:

- authentication warning;
- generic operation accepted;
- open a progress shell.

### After swap

Use when new content must exist:

- focus a returned field;
- read a returned target;
- close a modal only after canonical success markup is installed;
- announce a new server-rendered status.

### After settle

Use when final focus, layout, scrolling, or transition timing matters.

Do not choose after-settle for every event. Later timing increases coupling to presentation.

## 6. Command versus fact

Document every event as one:

### Command

A component requests that another owner attempt work.

Examples:

- `report-refresh-requested`;
- `delete-confirmed`;
- `filters-committed`.

A command may fail. Its name must not imply success.

### Fact

An authoritative outcome happened.

Examples:

- `record-deleted`;
- `profile-saved`;
- `cart-updated`.

Facts should be emitted only after the server outcome is known.

A useful workflow:

```text
delete-confirmed        command
DELETE /records/42      server attempt
record-deleted          fact after success
```

## 7. Event contract template

For each public event, record:

```text
Name:
Kind: command | fact
Producer:
Listener(s):
Payload:
Timing:
Source DOM boundary:
Does it bubble?
Failure behavior:
May it trigger a request?
Version-sensitive implementation:
Test cases:
```

Example:

```text
Name: profile-saved
Kind: fact
Producer: POST /profile success response
Listeners: profile modal shell, toast center
Payload: { id: number }
Timing: after swap
Source DOM boundary: htmx response trigger
Does it bubble? yes; listeners use .window
Failure behavior: not emitted on validation or authorization failure
May it trigger a request? no
Version-sensitive implementation: htmx 2 response-header semantics
Test cases: success, validation, slow response, repeated save
```

## 8. Avoid event soup

Warning signs:

- one event triggers another event that triggers a watcher that triggers a request;
- public events are named after element IDs;
- a generic `htmx:afterRequest` listener infers every business outcome;
- multiple components mutate the same global store after the same event;
- success is inferred by scraping response text;
- event payloads contain a complete private application state tree.

Repair:

1. reduce to one command and one authoritative fact;
2. let returned HTML carry representation changes;
3. use OOB swaps for related server-owned projections;
4. use Alpine events only for local presentation reactions;
5. document direction and failure behavior.

## 9. Event payload design

Good payloads are small and stable:

```json
{"id": 42}
```

```json
{"level": "success", "message": "Profile saved"}
```

Do not send secrets, permissions, or a full normalized entity graph.

Prefer IDs and small presentation facts. The server-rendered HTML remains the authoritative representation.

## 10. Dynamic request parameters

A semantic event payload is not automatically included in an htmx request. Use one of these explicit patterns.

### Pattern A: named hidden control

```html
<form
    id="bulk-delete-form"
    hx-delete="/records"
    hx-trigger="bulk-delete-confirmed from:window"
    hx-target="#records"
>
    <input id="selected-records" type="hidden" name="ids">
</form>
```

Alpine updates the hidden control before dispatching the command.

### Pattern B: `hx-include`

Keep related controls elsewhere and include them.

### Pattern C: narrow integration listener

Use `htmx:configRequest` in htmx 2 to add a value only when the feature contract genuinely requires it. Keep the handler scoped to a feature marker and test version migration.

### Pattern D: route-specific request element

Render one request element per record with the ID already present in the route.

The first two patterns are easiest to inspect.

## 11. Toast architecture

A small Alpine store may own transient toast presentation:

```html
<script>
document.addEventListener('alpine:init', () => {
    Alpine.store('toasts', {
        items: [],

        push(toast) {
            this.items.push({
                id: crypto.randomUUID(),
                ...toast
            })
        },

        remove(id) {
            this.items = this.items.filter(item => item.id !== id)
        }
    })
})
</script>

<div
    x-data
    @profile-saved.window="$store.toasts.push({
        level: 'success',
        message: 'Profile saved'
    })"
>
</div>
```

The store owns queue, timeout, and dismissal. It does not own durable notification records.

## 12. Event and OOB division

Use returned HTML or OOB swaps for authoritative representation changes:

- cart count;
- order summary;
- unread total;
- saved row;
- job status.

Use semantic events for local reactions:

- open drawer;
- close modal;
- show toast;
- move focus;
- expand a local explanation.

Do not open a drawer by sending an OOB replacement for the drawer’s local `open` state.

## 13. Testing event contracts

Test:

- event emitted exactly once;
- no fact on validation failure;
- payload shape;
- timing relative to swap;
- listener survives intended target swaps;
- listener is removed when component is destroyed;
- repeated requests do not create duplicate listeners;
- event does not accidentally trigger another request;
- htmx 2 event names are not confused with htmx 4 names.

## 14. Day lab: delete and save diplomacy

Build two workflows.

### Workflow A: custom delete confirmation

- Alpine requires the user to type the record name.
- Alpine updates a named hidden control.
- Alpine dispatches `record-delete-confirmed`.
- htmx performs the request.
- server returns authoritative list HTML and emits `record-deleted`.
- Alpine closes the dialog and a toast center announces success.

### Workflow B: profile modal

- Alpine owns modal visibility.
- htmx loads and submits the inner form.
- validation failure replaces the form and emits no success fact.
- success emits `profile-saved` after swap.
- Alpine closes and restores focus.

Write a contract for every event.

## Mini-tasks

### Task A: rename events

Replace:

- `run-button-7`;
- `set-open-false`;
- `htmx-finished`;
- `swap-cart`;
- `global-update`.

### Task B: command/fact split

Repair an event named `record-deleted` that fires before the delete request begins.

### Task C: payload plumbing

Make selected IDs enter a bulk request through a named control rather than assuming `$dispatch` payloads are request parameters.

### Task D: stop the chain

Flatten a five-hop event/watcher/request workflow into one command, one request, returned HTML, and one fact.

## Comprehension check

1. Why are semantic browser events a good Alpine–htmx bridge?
2. What is the difference between a command and a fact?
3. When should a success fact be emitted?
4. What does `HX-Trigger-After-Swap` guarantee?
5. Are response headers processed on normal redirects?
6. Does an Alpine event payload automatically become htmx parameters?
7. What should OOB swaps update?
8. What should an Alpine toast store not own?
9. Why are implementation-named events brittle?
10. What are two signs of event soup?

## Cheat sheet

```text
LOCAL INTENT                 -> Alpine $dispatch(command)
NETWORK REQUEST              -> htmx hx-trigger custom-event
AUTHORITATIVE RESULT         -> returned HTML
RELATED SERVER PROJECTION    -> OOB swap
LOCAL REACTION               -> fact event -> Alpine
PAYLOAD NEEDED BY SERVER     -> named control / hx-include / explicit handler
```

## Exit exercise

Design a “approve expense” workflow with a custom local confirmation, server authorization, an updated row, updated summary totals, modal closure, and a toast. Use one command, returned HTML plus OOB projections, and one fact. No global workflow store.

## Exit criteria

You pass when every cross-boundary action can be traced on one page from producer to listener to failure behavior.

## 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.
