Skip to main content Eirik's blog

A Flexible Form Pattern for Angular

A form field pattern that scales

Most form libraries eventually hit the same problem: every new input type starts to demand its own field component.

You begin with text input. Then textarea. Then phone number, date range, masked input, autocomplete, and so on. If each one owns label layout, validation UI, error timing, and accessibility wiring, consistency drifts and maintenance cost climbs.

The pattern below avoids that by separating the system into two parts:

  1. A reusable field shell component
  2. A projected control that implements a small contract

This split is what makes the system both consistent and flexible.

The core idea

The shell owns structure and shared behavior:

  • Label, hint, and error regions
  • Prefix/suffix slots
  • Required marker
  • Validation message switching (hint vs error)
  • Accessibility wiring (for, aria-describedby)
  • Container click to focus delegation
  • Visual states and variants (density, intent, disabled/invalid styling)

The projected control owns interaction:

  • Value entry and value model
  • How focus is handled internally
  • Whether it participates in Angular forms

The integration boundary is a contract (interface + injection token).

ts code snippet start

export interface FormFieldControl {
  readonly elementRef: ElementRef<HTMLElement>;
  readonly ngControl: NgControl | null;
  readonly disabled: boolean;
  readonly readonly: boolean;
  readonly required: boolean;

  setDescribedByIds(ids: string[]): void;
  onContainerClick(): void;
}

ts code snippet end

Keep the contract small. Small contracts are easier to implement correctly across many controls.

If this feels familiar

If this pattern sounds familiar, you are probably thinking of Material Angular form fields.

Material uses the same core architecture: a field shell (mat-form-field) paired with controls that implement a contract (MatFormFieldControl). The example in this post is intentionally simplified, but the design principle is the same:

  • The shell owns shared field behavior and presentation.
  • The control owns input interaction details.
  • A contract keeps both sides decoupled and reusable.

Why this works

1) You centralize hard-to-get-right behavior once

Form consistency problems are rarely about value input itself. They are usually about surrounding behavior:

  • Validation timing
  • Error visibility policy
  • Label/description wiring
  • Focus affordances
  • State styling and spacing

Putting those rules in one shell gives every control the same baseline UX by default.

2) You avoid component explosion

Without a contract, teams often build one component per field type (TextField, PhoneField, DateField, …), each repeating similar wrapper logic. With a contract, you can keep one shell and many controls.

That reduces maintenance and design drift over time.

3) You preserve local freedom where it matters

Each control remains free to model its own interaction: masking, parsing, keyboard handling, composite values, async lookups, etc. The shell does not constrain those details.

In short: standardize the frame, not the input mechanics.

How to implement it

1) Define the control contract

The shell needs only what it cannot infer safely:

  • Host element reference (focus + IDs)
  • Form state handle (NgControl | null)
  • State flags (disabled, readonly, required)
  • Two behavioral hooks (setDescribedByIds, onContainerClick)

Avoid adding value APIs to this contract unless absolutely necessary. Keep it integration-focused, not control-specific.

2) Build the shell around projected content

The shell should:

  • Render semantic regions (label, control, hint, error, optional prefix/suffix)
  • Decide when hint vs error is shown (for example: invalid && (touched || dirty))
  • Wire accessibility IDs to the control (aria-describedby)
  • Delegate container click to onContainerClick()

3) Provide a bridge for native inputs

Create a directive for input/textarea that implements the contract. This gives immediate adoption for common controls without extra wrappers.

html code snippet start

<app-form-field>
  <label appLabel>Email</label>
  <input appTextInput [formControl]="emailControl" required />
  <p appHint>We only use this for account notifications.</p>
  <p appError>Enter a valid email before continuing.</p>
</app-form-field>

html code snippet end

4) Add custom controls by implementing the same contract

For a phone input, date range picker, or composite value control:

  • Implement the contract
  • Provide the injection token
  • Expose NgControl when integrated with Angular forms

No changes are required in the shell.

Practical guidance

  • Treat the shell as infrastructure. Changes there affect every field.
  • Keep validation policy explicit and documented.
  • Write one contract conformance test for each custom control.
  • Prefer composition over wrappers unless the wrapper adds real domain value.

The result is a form system that stays coherent as your control catalog grows. It scales because consistency is centralized, while control behavior remains extensible.