심화 가이드
폼

커스텀 컨트롤

참고: 이 문서는 Signal Forms 핵심 가이드의 내용을 이해하고 있다고 가정합니다.

브라우저에 내장된 폼 컨트롤(input, select, textarea 등)은 일반적인 입력을 처리할 수 있지만, 애플리케이션에 따라 특별한 입력 기능이 필요할 때가 있습니다. 달력 UI가 있는 날짜 선택기, 서식 도구 모음이 있는 리치 텍스트 편집기, 자동완성 기능이 있는 태그 선택기 등은 직접 구현해야 합니다.

Signal Forms는 특정 인터페이스를 구현한 컴포넌트와 함께 동작할 수 있습니다. 컨트롤 인터페이스는 컴포넌트가 폼 시스템과 통신하는 데 필요한 프로퍼티와 시그널을 정의합니다. 컴포넌트가 이 인터페이스 중 하나를 구현하면, [formField] 디렉티브가 컨트롤을 폼 상태, 유효성 검사, 데이터 바인딩에 자동으로 연결합니다.

도움말: 커스텀 Signal Form 컨트롤은 별도의 호환성 코드 없이 시그널 폼, 반응형 폼, 템플릿 기반 폼에서 사용할 수 있습니다.

기본 커스텀 컨트롤 만들기

최소한의 기능부터 구현하고, 필요한 기능을 하나씩 추가해 봅시다.

기본 입력 컨트롤

기본 입력 컨트롤을 만들려면 FormValueControl 인터페이스를 구현하고, 필수 프로퍼티인 value 모델 시그널을 정의하면 됩니다.

import {Component, model} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';

@Component({
  selector: 'app-basic-input',
  template: `
    <div class="basic-input">
      <input
        type="text"
        [value]="value()"
        (input)="value.set($event.target.value)"
        placeholder="Enter text..."
      />
    </div>
  `,
})
export class BasicInput implements FormValueControl<string> {
  /** 현재 입력 값 */
  value = model('');
}

기본 체크박스 컨트롤

체크박스 형태의 컨트롤에는 다음 두 가지가 필요합니다:

  1. FormField 디렉티브가 폼 컨트롤로 인식할 수 있도록 FormCheckboxControl 인터페이스를 구현합니다.
  2. checked 모델 시그널을 정의합니다.
import {Component, model, ChangeDetectionStrategy} from '@angular/core';
import {FormCheckboxControl} from '@angular/forms/signals';

@Component({
  selector: 'app-basic-toggle',
  template: `
    <button type="button" [class.active]="checked()" (click)="toggle()">
      <span class="toggle-slider"></span>
    </button>
  `,
})
export class BasicToggle implements FormCheckboxControl {
  /** 토글의 선택 여부 */
  checked = model<boolean>(false);

  toggle() {
    this.checked.update((val) => !val);
  }
}

커스텀 컨트롤 사용하기

컨트롤을 만든 뒤에는 FormField 디렉티브를 추가해서 기본 입력 컨트롤을 사용하는 곳 어디에서나 사용할 수 있습니다:

import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
import {BasicInput} from './basic-input';
import {BasicToggle} from './basic-toggle';

@Component({
  imports: [FormField, BasicInput, BasicToggle],
  template: `
    <form novalidate>
      <label>
        Email
        <app-basic-input [formField]="registrationForm.email" />
      </label>

      <label>
        Accept terms
        <app-basic-toggle [formField]="registrationForm.acceptTerms" />
      </label>

      <button type="submit" [disabled]="registrationForm().invalid()">Register</button>
    </form>
  `,
})
export class Registration {
  registrationModel = signal({
    email: '',
    acceptTerms: false,
  });

  registrationForm = form(this.registrationModel, (schemaPath) => {
    required(schemaPath.email, {message: 'Email is required'});
    required(schemaPath.acceptTerms, {message: 'You must accept the terms'});
  });
}

참고: 스키마 콜백 함수가 받는 인자(이 예제의 schemaPath)는 폼의 모든 필드에 대한 경로를 제공하는 SchemaPathTree 객체입니다. 이 인자의 이름은 원하는 대로 지정해도 됩니다.

[formField] 디렉티브는 커스텀 컨트롤과 기본 입력 컨트롤에서 동일하게 동작합니다. Signal Forms는 두 컨트롤을 같은 방식으로 처리하기 때문에 유효성 검사, 상태 갱신, 데이터 바인딩이 자동으로 동작합니다.

컨트롤 인터페이스 이해하기

커스텀 컨트롤이 동작하는 방식을 확인했으니, 이제 Signal Forms와 어떻게 연동되는지 알아봅시다.

컨트롤 인터페이스

앞에서 만든 BasicInput 컴포넌트와 BasicToggle 컴포넌트는 각각 컨트롤 인터페이스를 구현합니다. Signal Forms는 이 인터페이스를 통해 각 컴포넌트와 상호작용하는 방법을 알 수 있습니다.

FormValueControl

FormValueControl은 텍스트 입력, 숫자 입력, 날짜 선택기, 드롭다운 선택 목록 등 하나의 값을 편집하는 대부분의 입력 컨트롤에 사용하는 인터페이스입니다. 컴포넌트가 이 인터페이스를 구현하면 다음과 같이 동작합니다:

  • 필수 프로퍼티: 컴포넌트에 value 모델 시그널을 정의해야 합니다.
  • FormField 디렉티브의 역할: 폼 필드의 값을 컨트롤의 value 시그널에 바인딩합니다.

중요: FormValueControl을 구현한 컨트롤에는 checked 프로퍼티가 없어야 합니다.

FormCheckboxControl

FormCheckboxControl은 토글, 스위치 등 불리언 값으로 켜짐/꺼짐 상태를 표현하는 체크박스 형태의 컨트롤에 사용하는 인터페이스입니다. 컴포넌트가 이 인터페이스를 구현하면 다음과 같이 동작합니다:

  • 필수 프로퍼티: 컴포넌트에 checked 모델 시그널을 정의해야 합니다.
  • FormField 디렉티브의 역할: 폼 필드의 값을 컨트롤의 checked 시그널에 바인딩합니다.

중요: FormCheckboxControl을 구현한 컨트롤에는 value 프로퍼티가 없어야 합니다.

선택적으로 사용하는 상태 프로퍼티

FormValueControl과 FormCheckboxControl은 모두 FormUiControl을 상속합니다. FormUiControl은 폼 상태와 연동할 때 선택적으로 사용할 수 있는 프로퍼티를 제공하는 기본 인터페이스입니다.

이 프로퍼티들은 모두 선택 사항입니다. 컨트롤에 필요한 프로퍼티만 구현하면 됩니다.

상호작용 상태

사용자가 컨트롤과 상호작용했는지 추적합니다:

프로퍼티 용도
touched 사용자가 필드와 상호작용했는지 여부
dirty 사용자가 값을 변경했는지 여부(원래 값으로 되돌려도 유지)

유효성 검사 상태

유효성 검사 결과를 사용자에게 표시합니다:

프로퍼티 용도
errors 현재 유효성 검사 오류의 배열
invalid 필드에 유효성 검사 오류가 있는지 여부
pending 비동기 유효성 검사가 진행 중인지 여부

사용 가능 상태

사용자가 필드와 상호작용할 수 있는지 제어합니다:

프로퍼티 용도
disabled 필드가 비활성화되었는지 여부
disabledReasons 필드가 비활성화된 이유
readonly 필드가 읽기 전용인지 여부(화면에 표시되지만 편집할 수 없음)
hidden 필드가 화면에서 숨겨졌는지 여부

참고: disabledReasons는 DisabledReason 객체의 배열입니다. 각 객체에는 필드 트리를 참조하는 fieldTree 프로퍼티와 선택적으로 사용하는 message 프로퍼티가 있습니다. 메시지는 reason.message로 참조할 수 있습니다.

유효성 검사 제약 조건

폼에서 유효성 검사에 사용하는 제약 조건 값을 전달받습니다:

프로퍼티 용도
required 필드가 필수 항목인지 여부
min 숫자의 최솟값(제약 조건이 없으면 undefined)
max 숫자의 최댓값(제약 조건이 없으면 undefined)
minLength 문자열의 최소 길이(제약 조건이 없으면 undefined)
maxLength 문자열의 최대 길이(제약 조건이 없으면 undefined)
pattern 값이 일치해야 하는 정규 표현식 패턴의 배열

필드 메타데이터

프로퍼티 용도
name 필드의 name 어트리뷰트(폼과 애플리케이션 전체에서 고유한 값)

이 프로퍼티들을 컨트롤에 구현하는 방법은 아래 상태 시그널 추가하기 섹션에서 확인할 수 있습니다.

FormField 디렉티브의 동작 방식

[formField] 디렉티브는 컨트롤이 어떤 인터페이스를 구현했는지 감지하고, 알맞은 시그널을 자동으로 바인딩합니다:

import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
import {CustomInput} from './custom-input';
import {CustomToggle} from './custom-toggle';

@Component({
  selector: 'app-my-form',
  imports: [FormField, CustomInput, CustomToggle],
  template: `
    <form novalidate>
      <app-custom-input [formField]="userForm.username" />
      <app-custom-toggle [formField]="userForm.subscribe" />
    </form>
  `,
})
export class MyForm {
  formModel = signal({
    username: '',
    subscribe: false,
  });

  userForm = form(this.formModel, (schemaPath) => {
    required(schemaPath.username, {message: 'Username is required'});
  });
}

팁: 폼 모델을 생성하고 관리하는 방법에 대해 자세히 알아보려면 폼 모델 가이드를 참고하세요.

[formField]="userForm.username"으로 바인딩하면 FormField 디렉티브는 다음과 같이 동작합니다:

  1. 컨트롤이 FormValueControl을 구현했는지 감지합니다.
  2. 내부적으로 userForm.username().value()를 참조하고, 이 값을 컨트롤의 value 모델 시그널에 바인딩합니다.
  3. 폼 상태 시그널(disabled(), errors() 등)을 컨트롤에 선택적으로 정의한 입력 시그널에 바인딩합니다.
  4. 시그널의 반응성을 통해 변경 사항이 자동으로 반영됩니다.

상태 시그널 추가하기

앞에서 만든 기본 컨트롤은 입력 기능은 동작하지만 폼 상태에는 반응하지 않습니다. 입력 시그널을 선택적으로 추가하면 비활성화 상태에 반응하고, 유효성 검사 오류를 표시하며, 사용자와의 상호작용을 추적할 수 있습니다.

자주 사용하는 상태 프로퍼티를 구현한 예제를 살펴봅시다:

import {Component, model, input, output, ChangeDetectionStrategy} from '@angular/core';
import {
  FormValueControl,
  WithOptionalFieldTree,
  ValidationError,
  DisabledReason,
} from '@angular/forms/signals';

@Component({
  selector: 'app-stateful-input',
  template: `
    @if (!hidden()) {
      <div class="input-container">
        <input
          type="text"
          [value]="value()"
          (input)="value.set($event.target.value)"
          [disabled]="disabled()"
          [readonly]="readonly()"
          [class.invalid]="invalid()"
          [attr.aria-invalid]="invalid()"
          (blur)="touch.emit()"
        />

        @if (invalid()) {
          <div class="error-messages" role="alert">
            @for (error of errors(); track error) {
              <span class="error">{{ error.message }}</span>
            }
          </div>
        }

        @if (disabled() && disabledReasons().length > 0) {
          <div class="disabled-reasons">
            @for (reason of disabledReasons(); track reason) {
              <span>{{ reason.message }}</span>
            }
          </div>
        }
      </div>
    }
  `,
})
export class StatefulInput implements FormValueControl<string> {
  // 필수 프로퍼티
  value = model<string>('');

  // 값을 쓸 수 있는 상호작용 상태 - 컨트롤에서 갱신합니다.
  touched = input<boolean>(false);
  touch = output<void>();

  // 읽기 전용 상태 - 폼 시스템에서 관리합니다.
  disabled = input<boolean>(false);
  disabledReasons = input<readonly WithOptionalFieldTree<DisabledReason>[]>([]);
  readonly = input<boolean>(false);
  hidden = input<boolean>(false);
  invalid = input<boolean>(false);
  errors = input<readonly WithOptionalFieldTree<ValidationError>[]>([]);
}

이제 이 컨트롤을 유효성 검사와 상태 관리 기능에 연결해서 사용할 수 있습니다:

import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required, email} from '@angular/forms/signals';
import {StatefulInput} from './stateful-input';

@Component({
  imports: [FormField, StatefulInput],
  template: `
    <form novalidate>
      <label>
        Email
        <app-stateful-input [formField]="loginForm.email" />
      </label>
    </form>
  `,
})
export class Login {
  loginModel = signal({email: ''});

  loginForm = form(this.loginModel, (schemaPath) => {
    required(schemaPath.email, {message: 'Email is required'});
    email(schemaPath.email, {message: 'Enter a valid email address'});
  });
}

사용자가 유효하지 않은 이메일 주소를 입력하면 FormField 디렉티브가 invalid()와 errors()를 자동으로 갱신합니다. 컨트롤은 이 값을 사용해서 유효성 검사 결과를 표시할 수 있습니다.

debounce('blur') 사용하기

debounce('blur') 규칙은 키를 누를 때마다 모델을 갱신하는 대신 필드에서 포커스가 벗어날 때까지 UI에서 폼 모델로의 갱신을 지연합니다. 기본 컨트롤은 포커스가 벗어났음을 폼에 자동으로 알립니다. 커스텀 컨트롤에서는 기본 blur 이벤트에 응답하여 touch 출력을 내보내야 이 기능이 동작합니다:

import {Component, model, output} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';

@Component({
  selector: 'app-custom-input',
  template: `
    <input
      type="text"
      [value]="value()"
      (input)="value.set($event.target.value)"
      (blur)="touch.emit()"
    />
  `,
})
export class CustomInput implements FormValueControl<string> {
  value = model('');
  touch = output<void>();
}

touch 출력을 구현하면 커스텀 컨트롤에서도 기본 입력과 동일하게 debounce('blur')가 동작합니다:

import {Component, signal} from '@angular/core';
import {debounce, form, FormField} from '@angular/forms/signals';
import {CustomInput} from './custom-input';

@Component({
  selector: 'app-root',
  imports: [CustomInput, FormField],
  template: `<app-custom-input [formField]="userForm.name" />`,
})
export class App {
  userModel = signal({name: ''});

  userForm = form(this.userModel, (schemaPath) => {
    debounce(schemaPath.name, 'blur');
  });
}

중요: touch는 focus가 아니라 blur(컨트롤에서 포커스가 벗어날 때)에 내보내세요. 컨트롤이 touch를 내보내지 않으면 필드가 touched 상태로 표시되지 않으며, debounce('blur')는 컨트롤이 내보낸 값을 폼 모델에 반영하지 않습니다.

값 변환하기

컨트롤에 표시하는 값의 형식과 폼 모델에 저장하는 값의 형식이 다를 때가 있습니다. 예를 들어 날짜 선택기에는 "January 15, 2024"를 표시하면서 모델에는 "2024-01-15"를 저장하거나, 통화 입력 컨트롤에는 "$1,234.56"을 표시하면서 모델에는 1234.56을 저장할 수 있습니다.

@angular/forms/signals의 transformedValue()로 UI에 표시하는 원시 값과 모델 값을 동기화하세요. 컨트롤의 value 모델 시그널, parse 함수, format 함수를 전달하면 원시 값을 담는 쓰기 가능한 시그널을 반환합니다:

  • format은 모델 값을 템플릿에 표시할 원시 값으로 변환합니다.
  • parse는 사용자가 입력한 값을 모델 값으로 변환하거나 파싱 오류를 반환합니다.
import {Component, model} from '@angular/core';
import {FormValueControl, transformedValue} from '@angular/forms/signals';

@Component({
  selector: 'number-input',
  template: `
    <input type="text" [value]="rawValue()" (input)="rawValue.set($event.target.value)" />
  `,
})
export class NumberInput implements FormValueControl<number | null> {
  readonly value = model.required<number | null>();

  protected readonly rawValue = transformedValue(this.value, {
    parse: (val: string): {value: number | null} => ({value: val ? Number(val) : null}),
    format: (val: number | null): string => val?.toString() ?? '',
  });
}

반환된 시그널에 rawValue.set(...)으로 값을 쓰면 parse를 실행하고 결과를 value에 기록합니다. reset(), 스키마 규칙, 애플리케이션의 다른 부분에서 모델을 변경하면 format을 다시 실행하여 원시 값을 모델과 일치시킵니다.

파싱 오류 알리기

일부만 입력한 날짜나 숫자 필드에 입력한 문자처럼 원시 값을 유효한 모델 값으로 바꿀 수 없는 경우가 있습니다. 위의 NumberInput도 이 문제가 있습니다. Number('abc')는 NaN이지만 parse는 그대로 모델에 기록합니다.

이 경우에는 {error}를 반환하세요:

export class NumberInput implements FormValueControl<number | null> {
  readonly value = model.required<number | null>();

  protected readonly rawValue = transformedValue(this.value, {
    parse: (val) => {
      const parsed = val ? Number(val) : null;

      return Number.isNaN(parsed)
        ? {error: {kind: 'parse', message: `${val} is not a number`}}
        : {value: parsed};
    },
    format: (val) => val?.toString() ?? '',
  });
}

모델을 갱신하면서 문제도 알리려면 value와 error를 함께 반환하세요.

컨트롤을 [formField]로 바인딩하면 파싱 오류가 필드에 자동으로 전달되어 유효성 검사 오류와 함께 errors() 시그널에 표시됩니다:

@Component({
  imports: [NumberInput, FormField],
  template: `
    <number-input [formField]="orderForm.amount" />

    @for (error of orderForm.amount().errors(); track $index) {
      <!-- {kind: 'parse', message: '...'}도 여기에 표시됩니다. -->
      <p class="error">{{ error.message }}</p>
    }
  `,
})
export class Order {
  orderModel = signal<{amount: number | null}>({amount: null});
  orderForm = form(this.orderModel);
}

파싱 오류가 있는 필드는 유효하지 않으므로 유효성 검사 규칙에 실패한 경우와 마찬가지로 제출을 차단합니다.

도움말: Signal Forms는 기본 입력에도 같은 방식을 사용합니다. 브라우저가 값을 파싱할 수 없으면(예: <input type="date">에 날짜를 일부만 입력한 경우) 필드에 parse 오류가 나타납니다. 자세한 내용은 기본 HTML 유효성 검사를 참고하세요.

초기화하기

필드에 reset()을 호출하면 남아 있는 파싱 오류를 지우고 모델 값으로 원시 값의 형식을 다시 지정합니다. 파싱할 수 없는 상태의 컨트롤도 정상적인 표시 값으로 돌아옵니다:

orderForm.amount().reset();

유효성 검사 연동하기

컨트롤은 유효성 검사 상태를 표시하지만, 직접 유효성 검사를 수행하지는 않습니다. 유효성 검사는 폼 스키마에서 수행합니다. 컨트롤은 FormField 디렉티브로부터 invalid()와 errors() 시그널을 전달받고, 앞에서 살펴본 StatefulInput 예제처럼 검사 결과를 표시합니다.

FormField 디렉티브는 required, min, max, minLength, maxLength, pattern과 같은 유효성 검사 제약 조건 값도 전달합니다. 컨트롤은 이 값들을 활용해서 UI를 개선할 수 있습니다:

export class NumberInput implements FormValueControl<number> {
  value = model<number>(0);

  // 스키마의 유효성 검사 규칙에서 전달받은 제약 조건 값
  required = input<boolean>(false);
  min = input<number | undefined>(undefined);
  max = input<number | undefined>(undefined);
}

스키마에 min()과 max() 유효성 검사 규칙을 추가하면 FormField 디렉티브가 이 값들을 컨트롤에 전달합니다. 이 값들을 HTML5 어트리뷰트에 적용하거나, 템플릿에서 제약 조건을 안내하는 데 활용하면 됩니다.

중요: 컨트롤 안에 유효성 검사 로직을 구현하면 안 됩니다. 유효성 검사 규칙은 폼 스키마에 정의하고, 컨트롤에는 검사 결과를 표시하세요:

권장하지 않음
// 피해야 하는 방식: 컨트롤에서 유효성을 검사합니다.
export class BadControl implements FormValueControl<string> {
  value = model<string>('');
  isValid() {
    return this.value().length >= 8;
  } // 이렇게 구현하면 안 됩니다!
}
권장
// 권장하는 방식: 스키마에서 유효성을 검사하고 컨트롤은 결과를 표시합니다.
accountForm = form(this.accountModel, (schemaPath) => {
  minLength(schemaPath.password, 8, {message: 'Password must be at least 8 characters'});
});

컨트롤 재사용하기

커스텀 컨트롤에는 유효성 검사에 대한 암묵적인 전제가 있는 경우가 많습니다. 이메일 입력이라면 사용하는 모든 폼에 required와 email 규칙이 필요합니다. 사용하는 쪽에서 매번 규칙을 다시 선언하도록 하는 대신, 컨트롤과 함께 사용할 스키마를 만들어 같은 모듈에서 내보내세요:

email-input.ts

import {schema, required, email} from '@angular/forms/signals';

export const emailFieldSchema = schema<string>((path) => {
  required(path, {message: 'Email is required'});
  email(path, {message: 'Enter a valid email address'});
});

컨트롤을 사용하는 쪽에서는 이 스키마를 불러와 apply()로 폼에 결합합니다:

registration.ts

import {form, apply} from '@angular/forms/signals';
import {emailFieldSchema} from './email-input';

registrationForm = form(this.registrationModel, (path) => {
  apply(path.email, emailFieldSchema);
});

apply()는 함께 제공된 스키마의 규칙을 부모 폼의 지정한 경로에 병합합니다. 기존 규칙을 덮어쓰지 않고 조합하므로 같은 필드에 규칙을 추가할 수도 있습니다. schema(), apply(), applyWhen()을 사용하는 조건부 조합에 대한 자세한 내용은 스키마 가이드를 참고하세요.

설계 시 고려 사항

컨트롤을 사용하는 모델은 모든 필드를 정의된 값으로 초기화해야 합니다. Signal Forms에서 undefined는 빈 값이 아니라 필드가 없다는 의미입니다. 재사용 가능한 이메일 컨트롤이라면 프로퍼티를 정의하지 않은 채 두지 말고 초기값으로 ''를 사용하세요. 초기값 선택에 대한 자세한 내용은 폼 모델 가이드를 참고하세요.

또한 컨트롤은 상태 관리를 위해 별도의 이펙트 함수를 등록하지 않아야 합니다. 폼 시스템이 내부 이펙트 함수로 필드 상태를 관리하며 컨트롤은 입력 시그널로 상태 변경을 전달받습니다. 값을 변환해야 한다면 effect() 대신 값 변환에서 설명한 transformedValue()를 사용하세요.

다음 단계

이 문서에서는 Signal Forms와 연동되는 커스텀 컨트롤을 만드는 방법을 알아봤습니다. Signal Forms의 다른 기능에 대해 알아보려면 다음 가이드를 참고하세요: