# Pages and state (/docs/products/dash-dots/developers/pages-and-state)

Each page has a controller, `controller.ts`. It holds the page's state (the data its view shows) and the code that changes it: mutations, actions and hooks.

This page shows an EDSA meter's balance and lets the person top it up. It loads the balance as it opens:

```ts title="pages/meter/controller.ts"
import state from './state';

export default definePage({
  initialState: state,
  hooks: {
    async onLoad(options) {
      this.setState({ meter: options.meter ?? '' });
      await this.actions.load();
    },
  },
  mutations: {
    setAmount: (_state, event: ValueEvent<number | null>) => ({
      amount: event.detail.value,
    }),
  },
  actions: {
    async load(state) {
      if (!state.meter) {
        this.setState({ error: 'Go back and choose a meter.' });
        return;
      }
      this.setState({ loading: true, error: '' });
      try {
        const response = await this.dash.network.http.request<{ balance: number }>({
          url: `${env.API_URL}/meters/${state.meter}`,
          as: 'json',
        });
        if (!response.ok) {
          throw new Error();
        }
        this.setState({ balance: response.body.balance });
      } catch {
        this.setState({ error: "We couldn't load your meter." });
      } finally {
        this.setState({ loading: false });
      }
    },
    confirm(state) {
      this.navigation.push('confirm', { meter: state.meter, amount: String(state.amount) });
    },
  },
});
```

```ts title="pages/meter/state.ts"
export default {
  meter: '',
  balance: 0,
  amount: null as number | null,
  loading: false,
  error: '',
};
```

The view shows a placeholder while it loads, and a way to try again if it fails:

```xml title="pages/meter/view.dml"
<View>
  <If condition="{{ loading }}">
    <Skeleton shape="text" lines="2" />
  </If>
  <ElseIf condition="{{ error }}">
    <Notice tone="danger" title="Not loaded">{{ error }}</Notice>
    <Button variant="secondary" onClick="load">Try again</Button>
  </ElseIf>
  <Else>
    <Text variant="display" numeric>{{ balance }} kWh</Text>
    <AmountInput label="Top-up amount" value="{{ amount }}" onChange="setAmount" />
    <Button onClick="confirm" disabled="{{ !amount }}">Top up</Button>
  </Else>
</View>
```

## State

Keep state to plain data: strings, numbers, booleans, `null`, arrays and objects. A `Date` or `Map` won't work in state, so store dates as text: `'2026-10-24'`.

Dot checks your code against the types in `initialState`. For a value that starts empty, give its type, as `amount: null as number | null` does above.

Never change state in place, like pushing onto an array. Set a new value. Each key replaces the old value whole, so to change one field of an object, pass the whole new object:

```ts
this.setState((current) => ({ profile: { ...current.profile, name: 'Aminata' } }));
```

## Mutations

A mutation is a quick function. It gets the state and the event, and returns only the keys that change, like `setAmount` above. It can't wait or call Dash, which keeps it easy to read and [test](/docs/products/dash-dots/developers/testing).

## Actions

An action does slower work. It can call your server or Dash, wait for the answer and open other pages. It changes state with `this.setState()`, like `load` above.

In actions and hooks, `this` also has:

- `this.state`, the state right now
- `this.mutations` and `this.actions`, which is how `onLoad` runs `load`
- `this.dash`, to [call Dash](/docs/products/dash-dots/developers/dash-apis)
- `this.captureView()`, to [make a picture of a `View`](/docs/products/dash-dots/developers/views#make-a-picture-of-a-view)

Actions also have `this.navigation`, to [open other pages](/docs/products/dash-dots/developers/navigation). Hooks don't have it.

The [Pages and dot reference](/docs/products/dash-dots/reference/pages-and-dot) lists the rest, including the setters for Dash's bars and page colors.

> **The state argument goes stale:** An action's `state` argument is the state when it started. After an `await`, the person may have typed something new. Read `this.state`, or pass a function to `setState`, so you don't overwrite it.

## Page hooks

| Hook              | When it runs                                                                                                                       |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `onLoad(options)` | The page opens, with its [launch options](/docs/products/dash-dots/developers/navigation#pass-data-with-launch-options)            |
| `onShow()`        | The page comes back to the top, or the person comes back to Dash                                                                   |
| `onHide()`        | Another page covers it, or the person leaves Dash                                                                                  |
| `onUnload()`      | The page closes                                                                                                                    |
| `onRefresh()`     | The person pulls the page down to refresh it. A spinner shows until the hook finishes. Without this hook, the page can't be pulled |

Hooks run one at a time, so `onShow` sees what `onLoad` loaded.

## Errors

When an action or hook throws and nothing catches it, the person sees nothing. Catch the errors you expect and put a message in state, as `load` does. For the codes Dash calls fail with, see [Handle errors](/docs/products/dash-dots/developers/dash-apis#handle-errors).

For the errors you didn't expect, add an `onError(error)` hook to `defineDot` in `dot.ts`, and send them to your error tracker.

In the simulator, an error nothing catches shows as a toast over the phone and a `[crash]` line in the browser's console, so you notice it while you build.
