> ## Documentation Index
> Fetch the complete documentation index at: https://aerojs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Reactivity

> Add client-side state and DOM bindings with <script is:state> — opt-in, no Virtual DOM.

Aero is static-first: build-time `{ }` interpolation renders once. When a page needs counters, toggles, form state, or UI that updates after load, enable reactivity. You keep writing `.html` with a fifth script type — no separate client framework required.

<Note>Reactivity defaults to `false`. Static sites ship no client binding runtime until you opt in.</Note>

## Enable reactivity

```ts aero.config.ts theme={"theme":"github-dark-default"}
import { defineConfig } from '@aero-js/core/config'

export default defineConfig({
	reactivity: true,
})
```

`aero check` reports templates that use reactive directives when the flag is off.

## `<script is:state>`

State scripts run in the browser after mount. Top-level `let` declarations become signals wired to markup:

```html theme={"theme":"github-dark-default"}
<script is:state>
	let count = 0
</script>

<section>
	<button on:click="{ count-- }">−</button>
	<span>{ count }</span>
	<button on:click="{ count++ }">+</button>
</section>
```

| Rule            | Detail                                                                                                                                           |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| One per file    | Only one `<script is:state>` per template                                                                                                        |
| Signals         | Top-level `let` bindings are reactive                                                                                                            |
| Helpers         | Top-level `function` declarations and `const` arrows are equivalent — use either style for handlers and shared logic that reads or updates state |
| Effects         | Top-level `$effect(() => { … })` runs imperative side effects when tracked signals change; return a function for cleanup                         |
| Imports         | Hoisted into mount scope, same as client scripts                                                                                                 |
| With `is:build` | Separate script blocks — state can **read** build-scope values to seed initial data (see below)                                                  |

See [Scripts](/getting-started/scripts) for how state scripts fit the script taxonomy.

### Seeding state from `is:build`

At render time, Aero runs `<script is:build>` then `<script is:state>` in the same scope. Declare data in the build script and assign it to state bindings:

```html theme={"theme":"github-dark-default"}
<script is:build>
	import site from '@content/site.ts'

	const initialItems = site.featuredPosts
</script>

<script is:state>
	let items = initialItems
</script>

<ul>
	<li for="{ const post of items }" key="{ post.id }">{ post.title }</li>
</ul>
```

Build script runs once at compile (or request) time. Owned `let` values are serialized into a `<script type="application/json" data-aero="state">` hydration payload so the client store matches the HTML on first paint. Derived bindings (for example `let total = items.length`) are recomputed at mount — they are not included in the payload.

Common sources for seed data:

| Source              | Example                                                           |
| ------------------- | ----------------------------------------------------------------- |
| Content collections | `const posts = await getCollection('blog')` → `let items = posts` |
| Page props          | In components: `const { count = Aero.bindable(0) } = Aero.props`  |
| Build constants     | `const limit = 10` → `let pageSize = limit`                       |

Build-time `{ expression }` in markup still renders static HTML. Use `is:state` when that data should stay reactive after load.

### Collection state (objects, arrays, Map, Set)

Top-level `let` bindings can hold collection values. Aero wraps them in a coarse-grained reactive proxy: any nested mutation re-notifies effects that read that binding.

```html theme={"theme":"github-dark-default"}
<script is:state>
	let formModel = { email: '', agree: false }
	let tags = new Set(['a', 'b'])
	let scores = new Map([[1, 'one']])
	let items = [1, 2, 3]
</script>
```

| Pattern                 | Updates UI?                     |
| ----------------------- | ------------------------------- |
| `formModel.email = 'x'` | Yes — in-place object field     |
| `items.push(4)`         | Yes — array mutation            |
| `tags.add('c')`         | Yes — Set method                |
| `scores.set(2, 'two')`  | Yes — Map method                |
| `items = [...items, 4]` | Yes — whole binding replacement |

Replacing the whole binding (`items = next`) still works and is a good fit when you want immutable list updates (for example with view transitions). In-place mutation is supported for ergonomics; both paths notify the same top-level signal.

**Granularity:** v1 is binding-level, not per-property. Reading `items.length` in a derived binding (`let hasItems = items.length > 0`) re-runs when `items` notifies — including after `push`, not only after `items = …`.

**Hydration:** Owned collection values serialize into `<script type="application/json" data-aero="state">`. Plain objects and arrays round-trip as JSON; `Map` and `Set` use `__aero` markers in the payload.

**Non-reactive values:** Primitives, functions, `Date`, class instances, DOM nodes, and typed arrays are stored as-is — mutating a `Date` in place does not notify.

### Helpers and handlers

Declare helpers at the top level of `<script is:state>` with normal JavaScript — block or expression bodies, TypeScript parameter types, and mixed styles in one script:

```html theme={"theme":"github-dark-default"}
<script is:state>
	import { withTransition } from '@scripts/utils/withTransition.ts'

	const createID = () => crypto.randomUUID().split('-').pop()

	let items = [{ id: createID() }]

	function add() {
		setItems([...items, { id: createID() }])
	}

	const remove = () => (items.length === 0 ? null : setItems(items.slice(1)))

	const setItems = next => withTransition(() => (items = next))
</script>

<button on:click="{ add() }">Add</button>
<button on:click="{ remove() }">Remove</button>
```

`function foo()` and `const foo = () => …` behave the same for templates and event handlers. Pure helpers that never touch reactive `let` bindings (for example `createID` above) stay at module scope; helpers that read or write state are available to bindings automatically.

## What you can bind

With reactivity enabled, markup can update at runtime:

| Pattern    | Example                                                      |
| ---------- | ------------------------------------------------------------ |
| Text       | `{ count }` or `text="{ label }"`                            |
| Events     | `on:click="{ count++ }"`, `on:submit.prevent="{ save() }"`   |
| Visibility | `show="{ open }"`                                            |
| Classes    | `class:is-active="{ active }"`                               |
| Properties | `disabled="{ saving }"`                                      |
| Forms      | `value="{ email }"`, `checked="{ agree }"`                   |
| Structure  | Reactive `if` / `else`, keyed `for`, `switch` / `case`       |
| Components | `bind:count="{ count }"` with `Aero.bindable()` in the child |

Attribute names also accept `aero-*` and `data-aero-*` prefixes.

## Hydration

Server-rendered HTML is the first paint. Aero emits a `<script type="application/json" data-aero="state">` JSON payload with owned `let` binding values from render time (including values seeded from `is:build`). At mount, the client store reads that payload and attaches bindings — no manual hydration API.

## Runtime access

For imperative inserts (hypermedia swaps or `innerHTML`), process bindings into the page store:

```ts theme={"theme":"github-dark-default"}
import aero from '@aero-js/core'

aero.getReactivityRuntime?.()?.process(element)
```

See [Process runtime fragments](/guide/reactivity/process).

## Hypermedia

Reactivity alone covers client-only UI. Pair it with [Hypermedia](/getting-started/hypermedia) when fragments come from the server. `busy` and post-swap binding processing require both flags.

## Next steps

<CardGroup cols={2}>
  <Card title="Reactivity recipes" icon="book-open" href="/guide/reactivity">
    Common patterns: forms, lists, reactive props, when to use Alpine instead.
  </Card>

  <Card title="Bindings reference" icon="link" href="/guide/reactivity/bindings">
    Full syntax for events, show, html, class, property, and model bindings.
  </Card>

  <Card title="Reactive effects" icon="bolt" href="/guide/reactivity/effects">
    Imperative side effects with `$effect` — web components, subscriptions, cleanup.
  </Card>

  <Card title="Structural updates" icon="git-branch" href="/guide/reactivity/structural">
    Reactive `if`, keyed `for`, and `switch`.
  </Card>

  <Card title="Reactive props" icon="arrow-left-right" href="/guide/reactivity/reactive-props">
    Share mutable state between parent and child components.
  </Card>
</CardGroup>
