# Alpine.js and htmx as Co-Equals

## Purpose

This is the operating guide for systems that use Alpine.js and htmx together without treating either as a helper bolted onto the other.

> **Version baseline:** The examples target Alpine.js 3.x and stable htmx 2.x. htmx 4 is currently a prerelease line with changed event names, inheritance defaults, and error-swapping behavior; verify its migration guide before applying these examples to htmx 4.

They are co-equal because each owns a different dimension of the interface:

- **htmx owns server interaction and the installation of server-rendered HTML.**
- **Alpine owns immediate, ephemeral, browser-local interface state.**

Neither owns the application alone.

The surrounding web platform retains its natural authority:

| Layer | Primary responsibility |
| --- | --- |
| Server and database | Durable truth, authorization, validation, transactions, workflow |
| Server templates | Canonical HTML representations of server state |
| URLs and routes | Navigable and shareable application locations |
| HTML | Structure, forms, links, controls, semantics, accessibility relationships |
| CSS | Layout, presentation, responsive behavior, ordinary transitions |
| htmx | Requests, request coordination, history integration, targeted HTML swaps |
| Alpine.js | Local reactivity, visibility, selection, focus, temporary drafts |
| Plain JavaScript | Algorithms, browser APIs, rich widget internals |
| Browser events | Communication across ownership boundaries |

The core rule is:

> Share a workflow, but do not share authority over the same state or DOM descendants.

---

## 1. The two constitutional rules

### Rule 1: one authoritative owner per state value

Copies and projections may exist, but one place must be the source of truth.

Example: cart quantity.

- The input holds the proposed value.
- Alpine may show an immediate provisional subtotal.
- htmx submits the control.
- The server validates inventory, price, tax, and the accepted quantity.
- The response replaces the authoritative cart representation.

The Alpine preview is a draft, not a second cart model.

Use state lifetime to choose ownership:

| State lifetime or meaning | Owner |
| --- | --- |
| Durable, shared, financial, permission-sensitive | Server |
| Must be bookmarkable or survive navigation | URL and route |
| Must be submitted | Native control or explicit request input |
| Exists for one local interaction | Alpine |
| Exists for one request | htmx lifecycle and CSS |
| Browser-only preference | Browser storage, optionally exposed through Alpine |
| Complex rich-widget internals | Plain JavaScript island |

A useful test:

> Would losing this value during refresh make the application incorrect or conceal real state?

When yes, Alpine must not be its only owner.

### Rule 2: one intentional writer per DOM boundary

Every significant region should have one declared mutation authority at a time.

A region is usually one of:

1. htmx-owned and replaceable;
2. Alpine-owned and stable;
3. a stable Alpine shell containing an htmx target;
4. an htmx-replaceable component containing disposable Alpine behavior;
5. a plain JavaScript island isolated from both;
6. static or native HTML requiring neither.

The most common integration failures are not syntax mistakes. They are undeclared ownership conflicts.

---

## 2. The canonical component shapes

### Shape A: stable Alpine shell, replaceable htmx interior

Use when local state should survive server refreshes.

```html
<aside
    x-data="{ open: false }"
    @cart-updated.window="open = true"
>
    <button
        type="button"
        @click="open = !open"
        :aria-expanded="open"
    >
        Cart
    </button>

    <section x-show="open" x-transition>
        <div id="cart-contents">
            <!-- server-owned cart fragment -->
        </div>
    </section>
</aside>
```

An add-to-cart action targets `#cart-contents`, not the `<aside>` root.

Ownership:

- Alpine owns `open` and local transition state;
- htmx owns requests and replacement inside `#cart-contents`;
- the server owns cart truth.

This is the preferred mixed shape.

### Shape B: htmx-replaceable component with disposable Alpine state

Use when the response should reset local interaction state.

```html
<article id="customer-42" x-data="{ menuOpen: false }">
    <button type="button" @click="menuOpen = !menuOpen">Actions</button>
    <div x-show="menuOpen">Temporary menu</div>

    <button
        hx-get="/customers/42/edit"
        hx-target="#customer-42"
        hx-swap="outerHTML"
    >
        Edit
    </button>
</article>
```

The returned edit component replaces the entire article. Losing `menuOpen` is correct.

### Shape C: htmx-only server component

Use when there is no meaningful local state.

Examples:

- pagination;
- delete action;
- refresh panel;
- load more;
- straightforward form submission.

Do not add Alpine merely for symmetry.

### Shape D: Alpine-only local component

Use when no server interaction is needed.

Examples:

- dropdown;
- local disclosure;
- already-present tabs;
- character counter;
- local preview;
- theme toggle.

Do not add htmx merely because it is available.

### Shape E: event-mediated handshake

Use when one owner needs to request action from another.

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

Example:

```html
<div x-data="{ confirming: false }">
    <button type="button" @click="confirming = true">Delete</button>

    <div x-show="confirming" role="dialog" aria-modal="true">
        <button
            type="button"
            @click="$dispatch('delete-confirmed', { id: 42 })"
        >
            Confirm
        </button>
    </div>
</div>

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

Alpine owns confirmation UI. htmx owns deletion. The server owns whether deletion is allowed.

### Shape F: isolated rich JavaScript island

Use for canvas, rich text editors, complex drag systems, media tools, high-frequency charts, or substantial undo/redo.

Rules:

- htmx does not replace the island root during normal operation;
- Alpine may own the surrounding shell but not duplicate internal island state;
- the island communicates through stable inputs and semantic events;
- durable changes use explicit server endpoints;
- teardown and reinitialization are documented.

Co-equality does not require forcing every browser behavior into Alpine or every update into HTML replacement.

---

## 3. Communication doctrine

### Use semantic browser events

Good names describe domain meaning:

- `cart-updated`;
- `profile-saved`;
- `dialog-close-requested`;
- `filters-committed`;
- `record-deleted`;
- `job-completed`.

Poor names expose implementation:

- `alpine-open-true`;
- `run-htmx-button`;
- `swap-panel-now`;
- `set-store-value`.

### Alpine to htmx

```html
<div x-data>
    <button
        type="button"
        @click="$dispatch('report-refresh-requested')"
    >
        Refresh
    </button>
</div>

<section
    hx-get="/report"
    hx-trigger="report-refresh-requested from:window"
    hx-target="this"
    hx-swap="outerHTML"
>
    <!-- current report -->
</section>
```

### Server and htmx to Alpine

The server can emit an event through an htmx response header.

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

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

Use the correct timing:

- immediate event for state-independent notification;
- after swap when new markup must exist;
- after settle for final focus, layout, or transition work.

### Event contract

Document each cross-boundary event with:

- event name;
- producer;
- listener;
- payload;
- timing;
- whether it is a command or a fact;
- failure behavior.

Avoid circular or multi-hop event chains.

---

## 4. Native controls are the shared state bridge

Values required by the server should live in real controls.

```html
<form
    x-data="{ advanced: false, quantity: 1 }"
    hx-post="/orders"
    hx-target="#order-form"
    hx-swap="outerHTML"
>
    <label>
        Quantity
        <input
            type="number"
            name="quantity"
            min="1"
            x-model.number="quantity"
        >
    </label>

    <button type="button" @click="advanced = !advanced">
        Advanced options
    </button>

    <fieldset x-show="advanced">
        <label>
            Delivery note
            <textarea name="delivery_note"></textarea>
        </label>
    </fieldset>

    <p>Draft quantity: <span x-text="quantity"></span></p>
    <button type="submit">Place order</button>
</form>
```

- Alpine gains reactivity.
- htmx serializes ordinary controls.
- non-enhanced form behavior remains possible.
- the server performs final validation.

Do not maintain one value in an input and a contradictory copy in a global store.

---

## 5. Request-state doctrine

The factual in-flight request belongs to htmx.

Use:

- `hx-indicator`;
- `htmx-request` CSS state;
- `hx-disabled-elt`;
- `hx-sync`;
- htmx lifecycle events.

```html
<form
    hx-post="/checkout"
    hx-target="#checkout"
    hx-indicator="#checkout-progress"
    hx-disabled-elt="find button, find input, find select"
>
    <!-- controls -->
    <button type="submit">Pay</button>
    <p id="checkout-progress" class="htmx-indicator" aria-live="polite">
        Processing…
    </p>
</form>
```

Alpine may own request-adjacent workflow state when it is more than loading, such as:

- an open progress drawer;
- a reversible optimistic preview;
- a multi-step local confirmation;
- local pause or dismissal controls.

Do not create two independent loading truths.

---

## 6. Lifecycle doctrine

htmx replaces DOM. Alpine state is attached to DOM component roots. That fact governs the design.

Before each target, choose:

### Reset

Replace the Alpine root and create a fresh instance.

### Survive

Keep the Alpine root stable and replace an inner child.

### Preserve

Use stable identity and `hx-preserve` only for exceptional elements whose identity must remain.

### Reconcile

Use morphing only after target redesign and ordinary replacement fail a demonstrated need.

### Alpine initialization and cleanup

Components that register external resources should provide `init()` and `destroy()` behavior.

Clean up timers, global listeners, observers, widgets, and subscriptions when htmx removes the component.

### Content inserted by htmx

Returned Alpine markup is a fresh component. Initialization must be repeatable and must not rely on removed nodes.

### Content inserted by Alpine

When an Alpine-created subtree contains `hx-*` attributes, call `htmx.process()` on the new region after insertion.

### Third-party widgets

Use `htmx:beforeCleanupElement` or the widget’s own teardown path when htmx will remove an initialized non-Alpine subsystem.

---

## 7. Focus, transitions, and accessibility

### Focus

- HTML and server templates own semantic order and error structure.
- Alpine owns focus movement inside local overlays and menus.
- htmx events indicate when replacement content exists.
- use post-settle timing when focus depends on the final DOM;
- restore focus when a modal or temporary panel closes;
- do not let returned `autofocus` and an Alpine effect compete.

### Transitions

- CSS owns appearance;
- Alpine transitions own Alpine visibility changes;
- htmx swap classes and timing own server replacement transitions;
- only one system should animate a particular entrance or exit.

A stable Alpine drawer may animate while an inner htmx fragment updates without animating the same shell again.

### Accessibility ownership

HTML and server templates own:

- labels;
- headings;
- landmarks;
- table semantics;
- button and link semantics;
- form error relationships;
- status regions.

Alpine owns where needed:

- `aria-expanded` and `aria-selected` derived from local state;
- keyboard roving focus;
- focus trap and restoration;
- local live-region updates.

htmx integration must preserve:

- logical focus after swaps;
- usable error markup;
- disabled and busy states;
- meaningful history and document titles;
- operation without hidden pointer-only assumptions.

---

## 8. Canonical combined features

### Forms

| Concern | Owner |
| --- | --- |
| Structure, labels, controls | HTML |
| Immediate native constraints | Browser |
| Local hints and previews | Alpine |
| Submission and replacement | htmx |
| Trusted validation | Server |
| Validation markup | Server templates |
| Focus and announcement after swap | Alpine or small integration code |
| Durable result | Server |

Return the complete form with values and errors rather than making Alpine reconstruct server validation from JSON.

### Active search

```html
<section x-data="{ filtersOpen: false }">
    <button
        type="button"
        @click="filtersOpen = !filtersOpen"
        :aria-expanded="filtersOpen"
    >
        Filters
    </button>

    <form
        x-show="filtersOpen"
        action="/search"
        method="get"
        hx-get="/search"
        hx-trigger="input changed delay:300ms from:input, change from:select, submit"
        hx-target="#results"
        hx-push-url="true"
        hx-sync="this:replace"
    >
        <input type="search" name="q">
        <select name="status">
            <option value="">Any status</option>
            <option value="open">Open</option>
        </select>
    </form>

    <div id="results">
        <!-- server-rendered results -->
    </div>
</section>
```

Ownership:

- Alpine: filter disclosure;
- controls: draft values;
- htmx: debounce, request replacement, history;
- server: search interpretation and result rendering;
- URL: applied query state.

### Modal with server content

```html
<div
    x-data="{ open: false }"
    @record-saved.window="open = false"
    @keydown.escape.window="open = false"
>
    <button
        hx-get="/records/42/edit"
        hx-target="#modal-body"
        @click="open = true"
    >
        Edit
    </button>

    <div x-show="open" x-cloak role="dialog" aria-modal="true">
        <button type="button" @click="open = false">Close</button>
        <div id="modal-body"></div>
    </div>
</div>
```

The shell remains stable. htmx replaces only the body. The server emits `record-saved` after success.

### Inline editing

- htmx loads the edit form;
- Alpine may reveal optional fields or show a local preview;
- htmx submits the form;
- the server validates;
- htmx replaces the entire record component;
- disposable Alpine state resets.

### Shopping cart

- server: actual cart, prices, tax, inventory;
- htmx: add, remove, update, rerender;
- Alpine: drawer open state and local visual behavior;
- OOB swaps: related count and total projections;
- response event: open drawer or show a toast.

### Tabs

Use Alpine when panels already exist and tab selection is temporary.

Use htmx when selection loads server content, changes authorization context, or deserves a URL.

Do not maintain one selected tab in Alpine and a contradictory one in the route.

### Toasts

A small Alpine store can own queue, timing, dismissal, and presentation. The server emits semantic events; htmx transports them. Alpine should not infer business success by scraping arbitrary returned text.

### Background jobs and real-time panels

- server: job truth;
- htmx polling or streaming: authoritative HTML updates;
- Alpine: panel visibility, local pause, selection, notification preferences;
- specialized JavaScript: high-frequency charts or canvas.

---

## 9. History and navigation

Meaningful navigation belongs to URLs.

Use htmx history for:

- search queries;
- pagination;
- selected records;
- applied filters;
- navigable tabs;
- multi-step server workflows.

Use Alpine for:

- whether a sidebar is open;
- temporary menu selection;
- a local unsaved draft;
- a browser preference.

Every pushed URL must return a valid complete page when visited directly.

When using boosted navigation, define policy for title, focus, scroll, analytics, global Alpine components, page initialization, and history restoration.

Alpine shell state should survive navigation only when its meaning survives navigation.

---

## 10. Error doctrine

### Server validation and business errors

Return usable server-rendered HTML with:

- submitted values;
- field-level errors;
- an error summary where appropriate;
- accessible relationships;
- an obvious next action.

With htmx 2.x, `4xx` and `5xx` responses are not swapped by default. Either return validation HTML with a swap-eligible status such as `200`, or deliberately configure error-response swapping and test that policy. Do not rely on accidental defaults.

### Network errors

Define a shared policy for:

- retry;
- offline state;
- timeout;
- preserved drafts;
- non-idempotent actions;
- status announcements.

### Authorization and session expiry

Handle globally only when the concern is truly cross-cutting. Do not put ordinary business workflow logic in generic htmx lifecycle listeners.

### Alpine errors

A local component failure should not leave durable state ambiguous. Keep local methods small, extract complex logic, and test cleanup paths.

---

## 11. Security doctrine

The server remains responsible for:

- authorization;
- authentication;
- CSRF policy;
- escaping and sanitization;
- validation;
- rate limits;
- transaction integrity;
- conflict handling.

Alpine restrictions:

- do not trust browser-local permissions;
- do not use `x-html` for untrusted content;
- do not persist secrets or authoritative values in browser storage.

htmx restrictions:

- request attributes do not bypass server authorization;
- request headers are not a complete CSRF defense by themselves;
- OOB targets and response events must not become an undocumented privilege channel;
- untrusted regions should not activate executable `hx-*` or `x-*` behavior.

Security decisions never become correct merely because the UI hides a control.

---

## 12. Project organization and naming

A practical structure distinguishes:

- pages: complete directly navigable documents;
- components: reusable server-rendered units;
- fragments: target-specific server responses;
- Alpine components: registered local behavior factories;
- JavaScript islands: specialized browser subsystems.

Name targets by domain role:

- `#cart-contents`;
- `#search-results`;
- `#profile-form`;
- `#job-status`.

Name Alpine components by UI behavior:

- `modalShell`;
- `filterPanel`;
- `combobox`;
- `toastCenter`.

Name events by business meaning:

- `profile-saved`;
- `cart-updated`;
- `filters-cleared`.

Do not name public contracts after a library implementation detail.

---

## 13. Standard component contract

For every mixed component, document:

### Identity

- stable server IDs;
- Alpine-local generated IDs;
- OOB or preservation requirements.

### State ownership

- server-owned values;
- URL-owned values;
- control-owned values;
- Alpine-owned values;
- derived previews.

### Replacement

- htmx target;
- swap strategy;
- whether Alpine roots reset or survive;
- preserved or isolated widgets.

### Requests

- trigger;
- method;
- inputs;
- concurrency policy;
- loading and disabled behavior.

### Events

- producer;
- consumer;
- payload;
- timing;
- command versus fact.

### History

- push, replace, or no URL change;
- direct-visit behavior;
- restoration expectations.

### Accessibility

- labels and roles;
- focus entry and restoration;
- live announcements;
- keyboard behavior.

### Errors and cleanup

- validation response;
- network failure;
- authorization failure;
- component destruction;
- third-party teardown.

---

## 14. Anti-patterns

### Alpine as a shadow backend

Large entity stores, duplicated permissions, JSON-driven rendering of server records, and perpetual reconciliation indicate a misplaced authority.

### htmx for local trivia

A request to open a menu or switch already-present tabs indicates a misplaced authority.

### Two loading systems

Use htmx request truth and let Alpine react only for broader local workflow.

### Two copies of form values

Keep submitted truth in native controls and bind Alpine to them.

### Broad destructive swaps

Shrink the target or move stable local state outside it.

### Competing collection writers

Do not use Alpine `x-for` for a list htmx also replaces.

### Event soup

Use semantic names, documented producers and consumers, and one directional flow.

### Store sprawl

Default to local `x-data`; use global stores only for genuinely shared browser UI state.

### OOB sprawl

Use OOB swaps for directly related authoritative projections, not page-wide choreography.

### Blanket preservation or morphing

Repair ownership boundaries first. Preserve or morph only where a demonstrated requirement justifies the complexity.

### Attribute overload

When a tag becomes unreadable, extract `Alpine.data()`, `Alpine.bind()`, server template helpers, partials, or plain functions.

---

## 15. Feature-development procedure

For every feature:

1. Define durable, URL, form, local, derived, and disposable state.
2. Define the complete server route, authorization, validation, and resulting representation.
3. Build semantic links, forms, controls, and regions.
4. Apply CSS for layout, focus, request states, and transitions.
5. Add htmx for partial server interaction.
6. Add Alpine for immediate local behavior.
7. Draw the DOM replacement boundary.
8. Define semantic event contracts.
9. Define initialization, destruction, focus, and processing behavior.
10. Test direct navigation, enhanced interaction, success, error, races, history, keyboard use, and refresh.

---

## 16. Normative checklist

A co-equal Alpine–htmx system should satisfy these rules:

1. The server is authoritative for durable, shared, security-sensitive, and business-significant state.
2. htmx owns ordinary HTTP interactions whose intended result is server-rendered HTML.
3. Alpine owns temporary, immediate, component-local browser state.
4. Native controls carry values required by the server.
5. URLs carry meaningful navigation state.
6. CSS owns ordinary presentation and animation.
7. Every DOM region has one intentional mutation owner.
8. Alpine roots remain outside htmx targets when their state must survive.
9. Replacing an Alpine root is correct when reset is intended.
10. Semantic browser events connect independent owners.
11. htmx request state precedes equivalent custom Alpine machinery.
12. Alpine stores remain small and browser-local.
13. OOB updates remain limited to related server projections.
14. Alpine-created `hx-*` markup is processed when necessary.
15. Initialization has matching cleanup.
16. Preservation, morphing, plugins, and extensions require demonstrated need.
17. Every pushed URL works directly.
18. Accessibility, security, history, and errors are part of the architecture.
19. Inline declarations stay locally understandable.
20. Rich client islands are allowed when evidence shows that a small local component is no longer proportionate.

---

## 17. The co-equal mental model

A strong application is not a client framework divided between two libraries.

It is a web-native system in which:

- the server decides what is true;
- server templates express that truth as HTML;
- htmx obtains and installs those representations;
- Alpine manages what is temporarily happening in the current interface;
- native controls carry important values;
- URLs carry meaningful locations;
- CSS presents the result;
- plain JavaScript handles specialized computation;
- browser events connect independent owners.

That is co-equality: full strength in distinct jurisdictions, with explicit contracts where a feature crosses the boundary.
