# Link Errors to Fields Programmatically

**MUST** · **ID:** `forms-error-programmatic-association` · **Category:** forms
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/forms-error-programmatic-association

> MUST: An invalid field must set `aria-invalid="true"` and point `aria-describedby` at its message. `aria-describedby` takes a space-separated LIST of ids — keep BOTH the persistent hint id and the error id; overwriting the hint with the error on validation is the common bug, and it makes the field stop explaining itself the moment it goes wrong. Orthogonal to `forms-ibelick-error-placement` (which governs WHERE the message sits): a well-placed error that is not associated still fails.

An error is not an error until the field points at it: set aria-invalid on the input and list the error and helper text ids in aria-describedby

From the forms and errors rules in ibelick's fixing-accessibility skill. This is the rule that red text alone cannot satisfy. `<input id="email" /><span>Invalid email</span>` renders a perfectly visible error and communicates nothing to the accessibility tree: the input still computes as valid, its accessible description is empty, and a screen-reader user who tabs back to the field to fix it hears the label and nothing else. Two attributes close the gap. `aria-invalid="true"` flips the field's computed state, so the error is announced as a property of the control rather than as loose text somewhere on the page. `aria-describedby` attaches the message — and it takes a space-separated LIST of ids, which is the detail people get wrong. A field usually has two things to say: a persistent hint ("We only email you about your account") and a transient error. The common bug is to overwrite the hint id with the error id on validation, so the moment the field goes invalid the user stops hearing why it exists. Keep both: `aria-describedby="email-err email-hint"`. This is orthogonal to forms-ibelick-error-placement, which governs WHERE the message sits visually ("where the action happens"); this one governs whether the message is LINKED to the control at all, wherever it sits. A correctly placed error that is not associated still fails, and an associated error still needs to be placed well.

## Rule snippet

```tsx
<input id="email" aria-invalid={!!err} aria-describedby={err ? "email-err email-hint" : "email-hint"} />
<p id="email-err">Enter a valid email address</p>
<p id="email-hint">We only email you about your account</p>
```

## Bad — do not do this

`forms-error-programmatic-association-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

/**
 * Reads what an assistive technology would actually compute for the input:
 * its accessible name, its validity state, and its accessible description
 * (the concatenated text of every element listed in aria-describedby).
 */
function useComputedA11y(
  ref: React.RefObject<HTMLInputElement | null>,
  deps: unknown[]
) {
  const [computed, setComputed] = useState({
    name: '—',
    state: '—',
    description: '—',
  });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const ids = (el.getAttribute('aria-describedby') ?? '')
      .split(/\s+/)
      .filter(Boolean);

    const description = ids
      .map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
      .filter(Boolean)
      .join(' ');

    setComputed({
      name: el.labels?.[0]?.textContent?.trim() || '(no accessible name)',
      state: el.getAttribute('aria-invalid') === 'true' ? 'invalid' : 'valid',
      description: description || '(no description)',
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return computed;
}

export function ErrorProgrammaticAssociationBad() {
  const [submitted, setSubmitted] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const computed = useComputedA11y(inputRef, [submitted]);

  return (
    <div className="w-full space-y-4">
      <form
        noValidate
        onSubmit={(e) => {
          e.preventDefault();
          setSubmitted(true);
        }}
        className="space-y-2"
      >
        <label htmlFor="epa-bad-email" className="block text-sm font-medium text-foreground">
          Email
        </label>

        {/* No aria-invalid, no aria-describedby: the error and the hint are
            visually adjacent to the field, but not attached to it. */}
        <input
          ref={inputRef}
          id="epa-bad-email"
          type="email"
          defaultValue="not-an-email"
          className={`w-full rounded-lg border px-3 py-2 text-sm bg-background text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
            submitted ? 'border-destructive' : 'border-input'
          }`}
        />

        <p className="text-xs text-muted-foreground">
          We only email you about your account.
        </p>

        {submitted && (
          <span className="block text-xs text-destructive">Invalid email</span>
        )}

        <button
          type="submit"
          className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Submit
        </button>
      </form>

      <div className="rounded-lg border border-border bg-card p-3 space-y-2">
        <p className="text-xs font-semibold text-foreground">
          What a screen reader announces (computed from the live DOM)
        </p>
        <dl className="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-1 text-xs">
          <dt className="text-muted-foreground">Name</dt>
          <dd className="text-foreground">{computed.name}</dd>
          <dt className="text-muted-foreground">State</dt>
          <dd className="text-error font-medium">{computed.state}</dd>
          <dt className="text-muted-foreground">Description</dt>
          <dd className="text-error font-medium">{computed.description}</dd>
        </dl>
      </div>

      <pre className="overflow-x-auto rounded-lg bg-muted p-3 text-xs text-foreground"><code>{`<input id="email" />
<span class="text-destructive">Invalid email</span>`}</code></pre>

      <p className="text-xs text-error">
        Red text a screen-reader user never hears. Submit the form: the field still announces as
        valid, with no description. The error exists only for people who can see it.
      </p>
    </div>
  );
}
```

## Good — do this

`forms-error-programmatic-association-good`

```tsx
import { useEffect, useRef, useState } from 'react';

/**
 * Reads what an assistive technology would actually compute for the input:
 * its accessible name, its validity state, and its accessible description
 * (the concatenated text of every element listed in aria-describedby).
 */
function useComputedA11y(
  ref: React.RefObject<HTMLInputElement | null>,
  deps: unknown[]
) {
  const [computed, setComputed] = useState({
    name: '—',
    state: '—',
    description: '—',
  });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const ids = (el.getAttribute('aria-describedby') ?? '')
      .split(/\s+/)
      .filter(Boolean);

    const description = ids
      .map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
      .filter(Boolean)
      .join(' ');

    setComputed({
      name: el.labels?.[0]?.textContent?.trim() || '(no accessible name)',
      state: el.getAttribute('aria-invalid') === 'true' ? 'invalid' : 'valid',
      description: description || '(no description)',
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return computed;
}

export function ErrorProgrammaticAssociationGood() {
  const [submitted, setSubmitted] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const computed = useComputedA11y(inputRef, [submitted]);

  // The error id is only in the list once the error exists; the hint id is
  // always there. Both are announced, in list order.
  const describedBy = ['epa-good-hint'];
  if (submitted) describedBy.unshift('epa-good-error');

  return (
    <div className="w-full space-y-4">
      <form
        noValidate
        onSubmit={(e) => {
          e.preventDefault();
          setSubmitted(true);
        }}
        className="space-y-2"
      >
        <label htmlFor="epa-good-email" className="block text-sm font-medium text-foreground">
          Email
        </label>

        <input
          ref={inputRef}
          id="epa-good-email"
          type="email"
          defaultValue="not-an-email"
          aria-invalid={submitted || undefined}
          aria-describedby={describedBy.join(' ')}
          className={`w-full rounded-lg border px-3 py-2 text-sm bg-background text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
            submitted ? 'border-destructive' : 'border-input'
          }`}
        />

        {/* Persistent helper text — always associated, error or not. */}
        <p id="epa-good-hint" className="text-xs text-muted-foreground">
          We only email you about your account.
        </p>

        {submitted && (
          <p id="epa-good-error" className="text-xs text-destructive">
            Invalid email — use the format name@example.com
          </p>
        )}

        <button
          type="submit"
          className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Submit
        </button>
      </form>

      <div className="rounded-lg border border-border bg-card p-3 space-y-2">
        <p className="text-xs font-semibold text-foreground">
          What a screen reader announces (computed from the live DOM)
        </p>
        <dl className="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-1 text-xs">
          <dt className="text-muted-foreground">Name</dt>
          <dd className="text-foreground">{computed.name}</dd>
          <dt className="text-muted-foreground">State</dt>
          <dd className="text-success font-medium">{computed.state}</dd>
          <dt className="text-muted-foreground">Description</dt>
          <dd className="text-success font-medium">{computed.description}</dd>
        </dl>
      </div>

      <pre className="overflow-x-auto rounded-lg bg-muted p-3 text-xs text-foreground"><code>{`<input
  id="email"
  aria-invalid="true"
  aria-describedby="email-err email-hint"
/>
<p id="email-hint">We only email you about your account.</p>
<p id="email-err">Invalid email — use the format name@example.com</p>`}</code></pre>

      <p className="text-xs text-success">
        aria-describedby takes a space-separated list of ids, not one — the error AND the persistent
        hint are both announced. Submit: the state flips to invalid and the description grows the
        error. That is the part people get wrong: they overwrite the hint id instead of adding to it.
      </p>
    </div>
  );
}
```

## References

- [fixing-accessibility (skill source)](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [MDN: aria-describedby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby)
- [MDN: aria-invalid](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid)
- [Understanding SC 3.3.1: Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html)
