# Day 2: Alpine Local-UI Fundamentals

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

Learn the routine, single-purpose Alpine operations used in ordinary server-rendered applications, with emphasis on small state, native controls, accessibility, and safe interaction beside htmx.

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

- create a small `x-data` component and keep its boundary obvious;
- use `x-on`, `x-show`, `x-if`, `x-bind`, `x-text`, `x-model`, `x-ref`, `$nextTick`, and `$watch` appropriately;
- decide when to extract `Alpine.data()`;
- avoid turning Alpine into a router, network layer, database cache, or duplicate renderer;
- implement disclosures, counters, previews, tabs, and local preferences.

## 1. Alpine’s job

An Alpine component is a DOM subtree with reactive browser-local state.

```html
<div x-data="{ open: false }">
    <button type="button" @click="open = !open">Toggle</button>
    <section x-cloak x-show="open">Local content</section>
</div>
```

Read it as:

1. `x-data` starts a state scope.
2. `open` is safe to discard.
3. `@click` changes it immediately.
4. `x-show` reflects it without fetching anything.

Good default Alpine state:

- disclosure, dropdown, modal, or drawer visibility;
- active keyboard index;
- selected already-present tab;
- draft text and character count;
- provisional calculations;
- temporary client-side selection;
- toast presentation;
- browser-only preferences.

Poor default Alpine state:

- permissions;
- prices, inventory, balances, or workflow truth;
- normalized server entities;
- server validation results;
- application routing;
- an independent request cache.

## 2. `x-data`: keep local state local

Inline state is ideal when the component remains small:

```html
<section x-data="{ expanded: false }">
    <button type="button" @click="expanded = !expanded">
        Show policy
    </button>
    <div x-cloak x-show="expanded">
        Policy details
    </div>
</section>
```

Extract a registered component when you need reuse, methods, cleanup, or a readable template:

```html
<script>
document.addEventListener('alpine:init', () => {
    Alpine.data('disclosure', () => ({
        open: false,

        toggle() {
            this.open = !this.open
        },

        close() {
            this.open = false
        }
    }))
})
</script>

<section x-data="disclosure">
    <button type="button" @click="toggle" :aria-expanded="open">
        Toggle
    </button>
    <div x-cloak x-show="open">Details</div>
</section>
```

Team rule: attributes declare behavior; they should not contain an entire application implementation.

## 3. `x-on` and event modifiers

Use `@event` for browser-local interaction.

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

    <div
        x-cloak
        x-show="open"
        @keydown.escape.window="open = false"
        @click.outside="open = false"
    >
        Menu content
    </div>
</div>
```

Useful modifiers:

| Modifier | Use |
| --- | --- |
| `.window` | listen at the window boundary |
| `.outside` | react to an outside click |
| `.prevent` | prevent the native default |
| `.stop` | stop propagation deliberately |
| `.escape`, `.enter`, etc. | keyboard-specific behavior |
| `.debounce` / `.throttle` | local event-rate control |

Do not use Alpine event handlers to recreate htmx request queues, target selection, swap logic, or history.

## 4. `x-show` versus `x-if`

### Use `x-show` when the subtree should remain mounted

```html
<div x-data="{ open: false }">
    <button type="button" @click="open = !open">Filters</button>
    <fieldset x-cloak x-show="open">
        <input name="q">
    </fieldset>
</div>
```

Advantages:

- child input values remain;
- focusable elements can be reused;
- transitions are supported;
- previously processed `hx-*` attributes remain on existing nodes.

### Use `x-if` when the subtree should truly cease to exist

```html
<div x-data="{ mounted: false }">
    <button type="button" @click="mounted = !mounted">
        Toggle editor
    </button>

    <template x-if="mounted">
        <section>
            A fresh subtree is created each time.
        </section>
    </template>
</div>
```

Important facts:

- `x-if` belongs on a `<template>`;
- the template contains one root element;
- local state in the removed subtree is destroyed;
- `x-transition` is not directly supported for `x-if`;
- if Alpine creates nodes containing `hx-*`, htmx may need `htmx.process()` on that new region. That integration is covered on Day 6.

## 5. `x-bind`: reflect state in attributes

```html
<div x-data="{ open: false }">
    <button
        type="button"
        @click="open = !open"
        :aria-expanded="open"
        :class="{ 'is-active': open }"
    >
        Toggle
    </button>
</div>
```

Good bindings:

- `aria-expanded`, `aria-selected`, `aria-pressed`;
- local classes;
- locally determined `disabled`;
- local `data-*` state;
- IDs generated for local relationships.

Do not bind authorization or prices from a stale browser copy.

## 6. `x-text` and `x-html`

Use `x-text` for safe local text output:

```html
<div x-data="{ note: '' }">
    <label>
        Note
        <textarea name="note" x-model="note"></textarea>
    </label>
    <p><span x-text="note.length"></span> characters</p>
</div>
```

Use `x-html` only for deliberately trusted HTML. In a server-rendered htmx application, the usual way to install server UI is to request server-rendered HTML through htmx, not to fetch a string and feed it to `x-html`.

Never pass unsanitized user content to `x-html`.

## 7. `x-model`: the strongest native bridge

Bind Alpine to a real control rather than maintaining a disconnected property.

```html
<form
    x-data="{ quantity: 1, unitPrice: 18 }"
    action="/cart/lines/42"
    method="post"
>
    <label>
        Quantity
        <input
            type="number"
            name="quantity"
            min="1"
            x-model.number="quantity"
        >
    </label>

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

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

The native control remains the submission model. Alpine observes it for immediate feedback. The server still decides the accepted quantity and total.

Useful modifiers include `.number`, `.boolean`, `.lazy`, `.blur`, `.debounce`, and `.throttle`. Select a modifier for local reactivity. Let htmx own network debounce and cancellation when a server request is involved.

## 8. Methods, getters, effects, and watchers

Prefer a getter for pure derived state:

```html
<div x-data="{
    price: 12,
    quantity: 1,
    get subtotal() {
        return this.price * this.quantity
    }
}">
    <input type="number" min="1" x-model.number="quantity">
    <output x-text="subtotal.toFixed(2)"></output>
</div>
```

Use a method for an action:

```html
<div x-data="{
    items: [],
    add(label) {
        this.items.push({ id: crypto.randomUUID(), label })
    }
}">
```

Use `x-effect` for a local side effect that should rerun with its dependencies. Keep it narrow. Do not make effects a hidden network layer.

Use `$watch` when old and new values matter:

```html
<div
    x-data="{ density: 'comfortable' }"
    x-init="$watch('density', (value, oldValue) => {
        console.debug({ value, oldValue })
    })"
>
```

Avoid changing the watched property in a way that creates a loop.

## 9. DOM references and Alpine timing

Use `x-ref` for scoped DOM access:

```html
<div x-data>
    <input x-ref="search">
    <button type="button" @click="$refs.search.focus()">
        Focus search
    </button>
</div>
```

Use `$nextTick` after Alpine changes its own DOM:

```html
<div x-data="{ open: false }">
    <button
        type="button"
        @click="open = true; $nextTick(() => $refs.panel.focus())"
    >
        Open and focus
    </button>

    <section
        x-cloak
        x-show="open"
        x-ref="panel"
        tabindex="-1"
    >
        Panel
    </section>
</div>
```

`$nextTick` means Alpine finished its reactive update. It does not mean an htmx response has arrived or settled.

## 10. `x-for`: small browser-owned collections only

```html
<div x-data="{ tags: ['urgent', 'review'] }">
    <template x-for="tag in tags" :key="tag">
        <span x-text="tag"></span>
    </template>
</div>
```

Appropriate:

- draft tags;
- temporary choices;
- a local toast queue;
- a small local preview.

Suspicious:

- hundreds of database records;
- lists also replaced by htmx;
- records whose truth lives on the server.

Use stable keys for reorderable items.

## 11. Alpine stores: an exception, not the starting point

A store is shared reactive browser state.

```html
<script>
document.addEventListener('alpine:init', () => {
    Alpine.store('ui', {
        sidebarOpen: false,
        toasts: []
    })
})
</script>
```

Reasonable contents:

- theme;
- global command-palette visibility;
- toast queue and timers;
- small non-authoritative preferences.

Do not put routing, permissions, cart truth, or a server entity cache there by default.

## 12. Accessibility SOP for local components

For a disclosure:

1. use a real button;
2. bind `aria-expanded`;
3. add `aria-controls` where useful;
4. keep keyboard behavior native;
5. avoid click-only non-button elements;
6. use `x-cloak` for initially hidden content.

For a custom tab interface:

1. use correct tab roles only when implementing the full keyboard model;
2. separate focused tab from selected tab if activation is manual;
3. generate stable local IDs with `x-id`/`$id` if needed;
4. do not use Alpine tabs for meaningful server routes that belong in the URL.

For an overlay:

1. prefer native `<dialog>` or a well-tested focus implementation;
2. restore focus to the opener;
3. support Escape;
4. keep labels and descriptions explicit;
5. do not make the whole `body` inert with a simplistic Alpine effect that also disables the dialog.

## 13. Day lab: local interaction toolkit

Build a single page with:

1. an FAQ disclosure;
2. a character counter using a named `<textarea>`;
3. already-present tabs whose selection is disposable;
4. a provisional invoice-line subtotal;
5. a local density preference;
6. a temporary tag list rendered by `x-for`.

For each component:

- keep the `x-data` root as small as possible;
- use no server request;
- write a one-line explanation of why refresh is safe;
- add keyboard and accessibility behavior;
- decide whether inline state is still readable or should become `Alpine.data()`.

## Mini-tasks

### Task A: choose `x-show` or `x-if`

Select one and explain why:

- a repeatedly opened dropdown;
- an expensive editor that should reset completely;
- a filter fieldset whose typed values must remain while hidden;
- a temporary preview that must release a ResizeObserver on close.

### Task B: remove duplicated form state

Repair a form that keeps `email` in an Alpine store, a separate `x-data` property, and the input value.

### Task C: extract a component

Turn a 12-line inline object with `open`, `toggle()`, Escape handling, and cleanup into `Alpine.data('popover', ...)`.

## Comprehension check

1. When is `x-show` better than `x-if`?
2. What does `.number` change on `x-model`?
3. Why is `x-text` generally safer than `x-html`?
4. What does `$nextTick` guarantee?
5. What should `x-for` normally render in an htmx application?
6. When is a global store justified?
7. Why should a server-required value appear in a named control?
8. What is wrong with using `$watch` as an invisible HTTP client?
9. Name two cases where plain JavaScript is more proportionate than Alpine.
10. What must happen to external resources when an Alpine component is destroyed?

## Cheat sheet

```text
x-data       local component scope
@click       local browser event
x-show       hide/show, node remains
x-if         create/destroy a fresh subtree
:attr        reflect local state in an attribute
x-text       safe local text
x-model      bind local state to a real control
x-for        small browser-owned collection
x-ref        scoped DOM reference
$nextTick    after Alpine's DOM update
$dispatch    semantic browser event
Alpine.data  reusable/extracted component
Alpine.store genuinely shared browser UI state
```

## Exit exercise

Implement a “bulk-selection toolbar” for rows already present in the page. Alpine may own the temporary selected IDs and toolbar visibility. Do not perform a server request yet. Include a hidden named control or a documented future serialization plan so Day 4 can submit the selection without creating a private, undocumented store protocol.

## Exit criteria

You pass when your Alpine components are:

- small and local;
- safe to discard unless explicitly persisted as a preference;
- based on native controls and semantic HTML;
- free of duplicated server truth;
- ready to coexist with a separately owned htmx target.

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