# Errors and Validation

Check values at the right moment, and explain each problem in a way that tells you how to fix it.

## Overview

Problems reach you from three sources, and each one needs its own message and recovery:

* **Validation.** A value you entered can't be used, such as a malformed address or an amount above your balance.
* **Network failure.** A request didn't complete, so nothing changed, such as a timeout while sending.
* **Transaction failure.** The network received a transaction and rejected it, such as a transfer that ran out of funds for its fee.

[Forms](/docs/patterns/forms) covers layout. [Feedback and Status](/docs/patterns/feedback-and-status) defines the Couldn't load and Failed states used here.

## Components

| Component                   | Role                                                                                         |
| --------------------------- | -------------------------------------------------------------------------------------------- |
| `Field.Error`               | States the problem under the invalid control, in place of its description.                   |
| `Field.Root` with `invalid` | Draws the red ring on the control inside and sets `aria-invalid` on it.                      |
| `Callout` with `negative`   | Summarizes several errors, or reports a failed submit above the actions.                     |
| `Empty`                     | Replaces a region whose content couldn't load, with a retry action.                          |
| `Tag` with `negative`       | Marks a transaction as Failed in activity and detail views.                                  |
| `Toast` with `negative`     | Reports a background action that failed, when the same action is also available on the page. |
| `Button` with `loading`     | Shows a retry in progress and blocks a duplicate request.                                    |

## Structure

1. Each invalid field shows its own `Field.Error`, which replaces the field's description while shown.
2. When a submit finds two or more problems, a negative `Callout` at the top lists them, with each item linking to its field.
3. A request that fails keeps the form and its values, and explains the failure in a `Callout` above the actions.
4. The primary action stays enabled, so pressing it again retries.

```tsx
import { AmountInput, Button, Callout, Field, Frame, Input, style, Surface, Toast } from '@tempoxyz/ds'
import { useRef, useState } from 'react'

export const fill = true
const balance = 1250

type Errors = { amount?: string | undefined; recipient?: string | undefined }

type Problem = { id: string; message: string }

function recipientError(value: string) {
  if (value === '') return "Enter the recipient's address."
  if (!/^0x[0-9a-fA-F]{40}$/.test(value)) return 'Enter a 42-character address that starts with 0x.'
  return undefined
}

function amountError(value: string) {
  if (value === '' || Number(value) === 0) return 'Enter an amount greater than 0.'
  if (Number(value) > balance) return 'Enter 1,250.00 USDC or less, your available balance.'
  return undefined
}

function PaymentForm() {
  const toast = Toast.useToast()
  const summaryRef = useRef<HTMLDivElement>(null)
  const [amount, setAmount] = useState('')
  const [errors, setErrors] = useState<Errors>({})
  const [pending, setPending] = useState(false)
  const [recipient, setRecipient] = useState('')
  const [summary, setSummary] = useState(false)

  const problems: Problem[] = []
  if (errors.recipient) problems.push({ id: 'recipient', message: errors.recipient })
  if (errors.amount) problems.push({ id: 'amount', message: errors.amount })

  return (
    <Surface
      {...styles.form()}
      render={
        <form
          noValidate
          onSubmit={(event) => {
            event.preventDefault()

            const next = { amount: amountError(amount), recipient: recipientError(recipient) }
            setErrors(next)

            const count = [next.recipient, next.amount].filter(Boolean).length
            setSummary(count > 1)

            // One problem moves focus to its field. Several move focus to the summary that lists them.
            // The summary mounts on this render, so focus moves to it once it exists.
            if (count > 1) return void setTimeout(() => summaryRef.current?.focus())
            if (next.recipient) return void document.getElementById('recipient')?.focus()
            if (next.amount) return void document.getElementById('amount')?.focus()

            setPending(true)
            setTimeout(() => {
              setPending(false)
              toast.add({
                description: `${amount} USDC to ${recipient.slice(0, 6)}…${recipient.slice(-4)}`,
                intent: 'positive',
                title: 'Payment sent',
              })
            }, 1200)
          }}
        />
      }
    >
      <Frame.Root>
        <Frame.Header>
          <Frame.Title>Send payment</Frame.Title>
        </Frame.Header>
        {summary && problems.length > 0 && (
          <Callout intent="negative" ref={summaryRef} tabIndex={-1}>
            <Callout.Icon />
            <Callout.Content>
              <p {...styles.heading()}>
                {problems.length === 1 ? 'Fix 1 problem' : `Fix ${problems.length} problems`} to
                send this payment
              </p>
              <ul {...styles.list()}>
                {problems.map((problem) => (
                  <li key={problem.id}>
                    <a
                      {...styles.link()}
                      href={`#${problem.id}`}
                      onClick={(event) => {
                        event.preventDefault()
                        document.getElementById(problem.id)?.focus()
                      }}
                    >
                      {problem.message}
                    </a>
                  </li>
                ))}
              </ul>
            </Callout.Content>
          </Callout>
        )}
        <Field.Root invalid={!!errors.recipient}>
          <Field.Label>Recipient</Field.Label>
          <Input.Group>
            <Input.Control
              autoComplete="off"
              onBlur={(event) => {
                // Format errors wait until you leave the field. An empty field waits for submit.
                const value = event.currentTarget.value
                if (value !== '') setErrors({ ...errors, recipient: recipientError(value) })
              }}
              onValueChange={(value) => {
                setRecipient(value)
                if (errors.recipient) setErrors({ ...errors, recipient: recipientError(value) })
              }}
              id="recipient"
              placeholder="0x…"
              spellCheck={false}
              value={recipient}
            />
          </Input.Group>
          <Field.Description>A Tempo address that starts with 0x.</Field.Description>
          <Field.Error match={!!errors.recipient}>{errors.recipient}</Field.Error>
        </Field.Root>
        <Field.Root invalid={!!errors.amount}>
          <Field.Label>Amount</Field.Label>
          <AmountInput.Root
            onValueChange={(value) => {
              setAmount(value)
              if (errors.amount) setErrors({ ...errors, amount: amountError(value) })
            }}
            value={amount}
          >
            <AmountInput.Control id="amount" placeholder="0.00" />
            <AmountInput.Suffix>USDC</AmountInput.Suffix>
            <AmountInput.Max
              aria-label="Use full balance"
              onClick={() => {
                setAmount(String(balance))
                setErrors({ ...errors, amount: undefined })
              }}
            />
          </AmountInput.Root>
          <Field.Description>Available: 1,250.00 USDC</Field.Description>
          <Field.Error match={!!errors.amount}>{errors.amount}</Field.Error>
        </Field.Root>
        <Frame.Footer>
          <Frame.Actions>
            <Button loading={pending} type="submit">
              Send payment
            </Button>
          </Frame.Actions>
        </Frame.Footer>
      </Frame.Root>
    </Surface>
  )
}

export function Demo() {
  return (
    <div {...styles.page()}>
      <Toast.Provider>
        <PaymentForm />
        <Toast.Viewport />
      </Toast.Provider>
    </div>
  )
}

namespace styles {
  export const form = style({
    boxSizing: 'border-box',
    maxWidth: '28rem !custom',
    width: '100% !custom',
  })

  export const heading = style({ margin: '0', typography: 'copy.14.strong' })

  export const link = style({ color: 'inherit !custom', textUnderlineOffset: '2px !custom' })

  export const list = style({ margin: '0', paddingInlineStart: '5' })

  export const page = style({
    backgroundColor: 'background.primary',
    display: 'grid',
    justifyItems: 'center',
    padding: '4',
  })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/PaymentValidation.tsx)

This reference layout is built from `Field`, `AmountInput`, and `Callout`. Type a short address and press Tab to see a format error. Then press Send payment with an empty amount to see the summary.

## Guidelines

### Validate at the Right Moment

| When      | What to check                                                           | Example                                           |
| --------- | ----------------------------------------------------------------------- | ------------------------------------------------- |
| On submit | Every field, including required fields left empty                       | Send payment with no amount                       |
| On blur   | The format of a value you typed, once you leave the field               | An address with 30 characters instead of 42       |
| On change | Only to clear an error that is already showing, once the value is fixed | The red ring disappears as you finish the address |

Don't report an empty field when you tab past it. Don't validate a format on every keystroke before you leave the field either, since every partial address is malformed.

Pass a `validate` function and `validationMode="onBlur"` to `Field.Root` to run a check when you leave the field. For checks on submit, run them in the form's `onSubmit` handler and pass each result to `invalid` on `Field.Root` and `match` on `Field.Error`.

### Write Errors That Say How to Fix the Value

State what to enter, in one sentence that starts with a verb, such as Enter a 42-character address that starts with 0x. Name the limit when there is one, such as your available balance.

**Do:** Say what a valid value looks like.

**Don't:** Write generic errors such as Invalid input or Error.

* Use the field's label in the message, so it still reads correctly in a summary.
* Keep the value you typed, so you can correct it instead of starting over.
* Leave out blame and apologies, such as You entered or Oops.

### Pair Every Ring With Text

`invalid` changes color and plays a short nudge, which not everyone can see. Always render a `Field.Error` with the reason, so the problem reaches screen readers and people who can't tell red from gray.

**Do:** Show a `Field.Error` with every invalid field.

**Don't:** Rely on the red ring alone to mark a problem.

### Summarize Several Errors

With one problem, move focus to its field when you submit. With two or more, render a negative `Callout` at the top of the form that names the count, lists each error as a link to its field, and receives focus.

Keep the inline errors as well. The summary tells you how much is wrong, and each `Field.Error` stays beside the field you're fixing.

### Report Failed Requests Where They Happen

When a submit fails because of the network, keep the form open with its values. Explain the failure in a negative `Callout` above the actions, and say whether anything changed. For payments, always say whether funds moved.

**Do:** Say what failed, what it means for your funds, and what to do next.

**Don't:** Show Something went wrong, or a status code, as the whole message.

Don't report a failed payment only in a `Toast`. It dismisses itself, and you need the message to decide what to do next.

### Explain Transaction Failures

A transaction that reached the network and failed is a different case from a request that never arrived. Mark it Failed with a negative `Tag`, keep it in activity, and say why in the row's description.

**Do:** Show the reason a transaction failed beside its Failed status.

**Don't:** Remove a failed transaction from activity, or show only a raw revert string.

### Retry Safely

```tsx
import { Button, Callout, Frame, Rows, style, Surface, Toast } from '@tempoxyz/ds'
import { useRef, useState } from 'react'

export const fill = true
function Review() {
  const toast = Toast.useToast()
  const attempts = useRef(0)
  const [failed, setFailed] = useState(false)
  const [pending, setPending] = useState(false)

  return (
    <Surface {...styles.card()}>
      <Frame.Root>
        <Frame.Header>
          <Frame.Title>Review payment</Frame.Title>
          <Frame.Description>Check the details, then send.</Frame.Description>
        </Frame.Header>
        <Rows.Root dividers="full">
          <Rows.Row spacing="none">
            <Rows.Label>To</Rows.Label>
            <span {...styles.mono()}>0x71C7…976F</span>
          </Rows.Row>
          <Rows.Row spacing="none">
            <Rows.Label>Amount</Rows.Label>
            25.00 USDC
          </Rows.Row>
          <Rows.Row spacing="none">
            <Rows.Label>Network fee</Rows.Label>
            0.001 USDC
          </Rows.Row>
        </Rows.Root>
        <Frame.Footer {...styles.footer()}>
          {failed && (
            <Callout intent="negative">
              <Callout.Icon />
              <Callout.Content>
                Couldn't reach the Tempo network, so your payment wasn't sent and no funds moved.
                Check your connection, then try again.
              </Callout.Content>
            </Callout>
          )}
          <Frame.Actions>
            <Button disabled={pending} variant="secondary">
              Back
            </Button>
            <Button
              loading={pending}
              onClick={() => {
                attempts.current += 1
                setPending(true)
                setTimeout(() => {
                  setPending(false)
                  // The first attempt fails so you can see the retry. The second succeeds.
                  if (attempts.current % 2 === 1) return setFailed(true)
                  setFailed(false)
                  toast.add({ intent: 'positive', title: 'Payment sent' })
                }, 1200)
              }}
            >
              Send payment
            </Button>
          </Frame.Actions>
        </Frame.Footer>
      </Frame.Root>
    </Surface>
  )
}

export function Demo() {
  return (
    <div {...styles.page()}>
      <Toast.Provider>
        <Review />
        <Toast.Viewport />
      </Toast.Provider>
    </div>
  )
}

namespace styles {
  export const card = style({
    boxSizing: 'border-box',
    maxWidth: '28rem !custom',
    width: '100% !custom',
  })

  export const footer = style({ rowGap: '4' })

  export const mono = style({ fontFamily: 'mono' })

  export const page = style({
    backgroundColor: 'background.primary',
    display: 'grid',
    justifyItems: 'center',
    padding: '4',
  })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/PaymentRetry.tsx)

The first press in this example fails so you can see the retry path. The second succeeds.

* Let the primary action retry a failed submit. Don't add a second Retry button beside it.
* Set `loading` on the button during each attempt, which blocks a duplicate request.
* Offer Try again for regions that couldn't load, as a `Callout.Action` or a button in `Empty.Actions`.
* Retry only when you know the first attempt didn't reach the network. When the outcome is unknown, show Pending and check the transaction's status before you offer to send again.
* Don't retry validation errors automatically. Nothing changes until the value does.
* After repeated failures, point to a next step, such as a status page or support.

## States

| State    | What you show                                                                                                        |
| -------- | -------------------------------------------------------------------------------------------------------------------- |
| Default  | Fields with descriptions and no errors. Nothing is checked before you interact.                                      |
| Loading  | The submit button shows `loading` while a check or request runs. Errors from the previous attempt stay visible.      |
| Error    | `Field.Error` for each invalid field, a summary `Callout` for several, and a failure `Callout` for a failed request. |
| Empty    | A required field left empty shows its error only after you submit.                                                   |
| Disabled | Skip validation for disabled fields. A disabled control drops `aria-invalid`, so an error on it goes unannounced.    |
| Success  | Errors clear as each value is fixed. After a successful submit, confirm with a `Toast` or the next screen.           |

## Accessibility

* A shown `Field.Error` joins the control's `aria-describedby`, so it's read when the control is focused.
* `invalid` on `Field.Root` or `Input.Group` sets `aria-invalid` on `Input.Control`. `success` sets no ARIA state.
* A negative `Callout` renders `role="alert"`, so a summary or failure message is announced when it appears.
* Move focus to the summary or the single invalid field on submit, so keyboard and screen reader users start where the problem is.
* Make each summary item a link that focuses its field.
* Don't communicate an error with color, an icon, or motion alone. The nudge on `invalid` stops under reduced motion.

## Related

* [Field](/docs/components/field) for labels and error messages
* [Callout](/docs/components/callout) for summaries and failures
* [Toast](/docs/components/toast) for background failures
* [Forms](/docs/patterns/forms) for layout and actions
* [Feedback and Status](/docs/patterns/feedback-and-status) for the Failed and Couldn't load states
* [Empty States](/docs/patterns/empty-states) for regions that couldn't load
