이 가이드에서는 기존 반응형 폼과의 연동을 중심으로, 기존 코드를 Signal Forms로 전환하는 방법을 알아봅니다.
compatForm으로 상위 폼부터 전환하기
기존 반응형 FormControl 인스턴스를 Signal Form 안에서 사용해야 할 때가 있습니다.
다음과 같은 컨트롤에 유용합니다:
- 복잡한 비동기 로직이 있는 컨트롤
- 아직 전환하지 않은 복잡한 RxJS 연산자를 사용하는 컨트롤
- 기존 서드 파티 라이브러리와 연동하는 컨트롤
Signal Form에 FormControl 통합하기
특수한 enterprisePasswordValidator를 사용하는 기존 passwordControl이 있다고 해봅시다.
검증 함수를 다시 작성하는 대신 이 컨트롤을 시그널 상태에 연결할 수 있습니다.
compatForm을 사용하면 됩니다:
import {signal} from '@angular/core';
import {FormControl, Validators} from '@angular/forms';
import {compatForm} from '@angular/forms/signals/compat';
// 1. 특수한 검증 함수를 사용하는 기존 컨트롤입니다.
const passwordControl = new FormControl('', {
validators: [Validators.required, enterprisePasswordValidator()],
nonNullable: true,
});
// 2. 폼 상태 시그널 안에 포함합니다.
const user = signal({
email: '',
password: passwordControl, // 기존 컨트롤을 그대로 중첩합니다.
});
// 3. 폼을 생성합니다.
const f = compatForm(user);
// 시그널 트리로 값에 접근합니다.
console.log(f.email().value()); // 현재 이메일 값
console.log(f.password().value()); // passwordControl의 현재 값
// 반응형 폼 상태를 자동으로 연결합니다.
const isPasswordValid = f.password().valid();
const passwordErrors = f.password().errors(); // 기존 검증 함수를 통과하지 못하면 CompatValidationError를 반환합니다.
템플릿에서는 기존 컨트롤에 바인딩하는 일반적인 반응형 폼 문법을 사용합니다:
<form novalidate>
<div>
<label>
Email:
<input [formField]="f.email" />
</label>
</div>
<div>
<label>
Password:
<input [formField]="f.password" type="password" />
</label>
@if (f.password().touched() && f.password().invalid()) {
<div class="error-list">
@for (error of f.password().errors(); track error) {
<p>{{ errorMessage(error) }}</p>
}
</div>
}
</div>
</form>
기존 검증 함수에서 발생한 오류는 CompatValidationError입니다.
이 오류에는 message가 설정되지 않으며, 검증 함수가 반환한 오류 객체는 대신 context에서 확인할 수 있습니다:
import {ValidationError} from '@angular/forms/signals';
import {CompatValidationError} from '@angular/forms/signals/compat';
errorMessage(error: ValidationError) {
if (error instanceof CompatValidationError) {
return (error.context as {message?: string} | null)?.message ?? error.kind;
}
return error.message ?? error.kind;
}
Signal Form에 FormGroup 통합하기
FormGroup 전체를 감쌀 수도 있습니다.
주소 입력 영역처럼 재사용하는 폼의 일부를 여전히 기존 반응형 폼으로 관리하는 경우에 자주 사용하는 방식입니다.
import {signal} from '@angular/core';
import {FormGroup, FormControl, Validators} from '@angular/forms';
import {compatForm} from '@angular/forms/signals/compat';
import {required} from '@angular/forms/signals';
// 1. 자체 유효성 검사 로직이 있는 기존 주소 그룹입니다.
const addressGroup = new FormGroup({
street: new FormControl('123 Angular Way', Validators.required),
city: new FormControl('Mountain View', Validators.required),
zip: new FormControl('94043', Validators.required),
});
// 2. 하나의 값처럼 상태에 포함합니다.
const checkoutModel = signal({
customerName: 'Pirojok the Cat',
shippingAddress: addressGroup,
});
const f = compatForm(checkoutModel, (p) => {
required(p.customerName);
});
shippingAddress 필드는 Signal Form 트리의 한 갈래가 됩니다.
.control()로 내부의 기존 컨트롤에 접근해서 템플릿에 중첩 컨트롤을 바인딩할 수 있습니다:
<form novalidate>
<h3>Shipping Details</h3>
<div>
<label>
Customer Name:
<input [formField]="f.customerName" />
</label>
@if (f.customerName().touched() && f.customerName().invalid()) {
<div class="error-list">
<p>Customer name is required.</p>
</div>
}
</div>
<fieldset>
<legend>Address</legend>
@let street = f.shippingAddress().control().controls.street;
<div>
<label>
Street:
<input [formControl]="street" />
</label>
@if (street.touched && street.invalid) {
<div class="error-list">
<p>Street is required</p>
</div>
}
</div>
@let city = f.shippingAddress().control().controls.city;
<div>
<label>
City:
<input [formControl]="city" />
</label>
@if (city.touched && city.invalid) {
<div class="error-list">
<p>City is required</p>
</div>
}
</div>
@let zip = f.shippingAddress().control().controls.zip;
<div>
<label>
Zip Code:
<input [formControl]="zip" />
</label>
@if (zip.touched && zip.invalid) {
<div class="error-list">
<p>Zip Code is required</p>
</div>
}
</div>
</fieldset>
</form>
값에 접근하기
compatForm은 FormControl 수준에서 값에 대한 접근을 대신 처리하지만, 폼 전체의 값에는 컨트롤 자체가 유지됩니다:
const passwordControl = new FormControl('password' /** ... */);
const user = signal({
email: '',
password: passwordControl, // 기존 컨트롤을 그대로 중첩합니다.
});
const form = compatForm(user);
form.password().value(); // 'password'
form().value(); // { email: '', password: FormControl}
폼 전체의 값이 필요하다면 직접 구성해야 합니다:
const formValue = computed(() => ({
email: form.email().value(),
password: form.password().value(),
})); // {email: '', password: ''}
하위 컨트롤부터 전환하기
FormGroup에 Signal Form 통합하기
SignalFormControl을 사용하면 시그널 기반 폼을 일반적인 FormControl로 제공할 수 있습니다.
상위 FormGroup 구조를 유지하면서 폼의 말단 노드부터 시그널로 전환할 때 유용합니다.
import {Component, signal} from '@angular/core';
import {ReactiveFormsModule, FormGroup} from '@angular/forms';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {required} from '@angular/forms/signals';
@Component({
// ...
imports: [ReactiveFormsModule],
})
export class UserProfile {
// 1. SignalFormControl을 만들고 Signal Forms 규칙을 사용합니다.
emailControl = new SignalFormControl('', (p) => {
required(p, {message: 'Email is required'});
});
// 2. 기존 FormGroup에서 사용합니다.
form = new FormGroup({
email: this.emailControl,
});
}
SignalFormControl은 Signal Forms와 반응형 폼 사이에서 값을 양방향으로 동기화합니다:
- 시그널 → 반응형 폼: Signal Forms로 값을 변경하면 반응형 폼 컨트롤에 즉시 반영됩니다.
// Signal Forms에서 값을 변경합니다.
this.emailControl.fieldTree().value.set('new@example.com');
// 반응형 폼에 변경 사항이 반영됩니다.
console.log(this.form.value); // {email: 'new@example.com'}
- 반응형 폼 → 시그널: 상위
FormGroup으로 값을 변경하면 Signal Forms 상태에 반영됩니다.
// 반응형 폼에서 값을 변경합니다.
this.form.patchValue({email: 'other@example.com'});
// Signal Forms에 변경 사항이 반영됩니다.
console.log(this.emailControl.fieldTree().value()); // 'other@example.com'
SignalFormControl 바인딩하기
FormGroup에서 SignalFormControl을 사용하려면 컨트롤로 전달하고, 템플릿에서는 .fieldTree로 바인딩합니다:
readonly emailControl = new SignalFormControl('', (p) => { required(p); });
readonly form = new FormGroup({
name: new FormControl('Alice'),
email: this.emailControl,
});
<form [formGroup]="form">
<!-- 일반 컨트롤 -->
<input formControlName="name" />
<!-- 시그널 컨트롤 -->
<input [formField]="emailControl.fieldTree" />
</form>
<!-- 피해야 하는 방식: SignalFormControl에 formControlName이나 [formControl] 사용하기 -->
<input formControlName="email" />
<input [formControl]="emailControl" />
SignalFormControl이 시그널 대신 값을 받는 이유
일반적인 Signal Forms에서는 form(mySignal)처럼 시그널을 전달해서 폼을 생성합니다.
하지만 SignalFormControl은 첫 번째 인자로 문자열이나 객체 같은 값 자체를 받습니다:
// 시그널이 아닌 값 자체를 받습니다.
const userControl = new SignalFormControl({
email: 'pirojok@example.com',
});
SignalFormControl은 내부에서 시그널을 생성해서 값 쓰기를 가로채고, 반응형 폼에서 기대하는 동기 방식의 갱신을 실행합니다.
내부 시그널에는 .sourceValue로 접근할 수 있습니다:
const value = userControl.sourceValue();
컨트롤 비활성화와 활성화
SignalFormControl은 enable(), disable()처럼 활성화/비활성화 상태를 명령형으로 변경하는 API를 의도적으로 지원하지 않습니다.
컨트롤 상태는 시그널 상태와 규칙에서 도출되어야 하기 때문입니다.
활성화나 비활성화 메서드를 실행하면 오류가 발생합니다.
import {signal, effect} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
export class UserProfile {
readonly emailControl = new SignalFormControl('');
readonly isLoading = signal(false);
constructor() {
// 에러가 발생합니다.
effect(() => {
if (this.isLoading()) {
this.emailControl.disable();
} else {
this.emailControl.enable();
}
});
}
}
대신 비활성화 규칙을 사용하세요:
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {disabled} from '@angular/forms/signals';
export class UserProfile {
readonly isLoading = signal(false);
readonly emailControl = new SignalFormControl('', (p) => {
// isLoading이 true이면 컨트롤이 비활성화됩니다.
disabled(p, {when: () => this.isLoading()});
});
async saveData() {
this.isLoading.set(true);
// ... 저장을 수행합니다 ...
this.isLoading.set(false);
}
}
동적으로 조작하기
SignalFormControl은 addValidators(), removeValidators(), setValidators()처럼 검증 함수를 명령형으로 추가하거나 제거하는 API를 의도적으로 지원하지 않습니다.
이 메서드들을 실행하면 오류가 발생합니다.
export class UserProfile {
readonly emailControl = new SignalFormControl('');
readonly isRequired = signal(false);
toggleRequired() {
this.isRequired.update((v) => !v);
// 에러가 발생합니다.
if (this.isRequired()) {
this.emailControl.addValidators(Validators.required);
} else {
this.emailControl.removeValidators(Validators.required);
}
}
}
대신 applyWhen 규칙으로 조건에 따라 검증 함수를 적용하세요:
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {applyWhen, required} from '@angular/forms/signals';
export class UserProfile {
readonly isRequired = signal(false);
readonly emailControl = new SignalFormControl('', (p) => {
// isRequired가 true이면 필수 컨트롤이 됩니다.
applyWhen(
p,
() => this.isRequired(),
(p) => {
required(p);
},
);
});
}
오류를 수동으로 지정하기
setErrors()와 markAsPending() 메서드는 지원하지 않습니다.
Signal Forms에서는 유효성 검사 규칙과 비동기 유효성 검사 상태에 따라 오류가 결정됩니다.
오류를 표시해야 한다면 스키마의 유효성 검사 규칙을 통해 선언적으로 지정해야 합니다.
상태 클래스 자동 적용하기
반응형 폼과 템플릿 기반 폼은 컨트롤 상태에 따라 스타일을 쉽게 적용할 수 있도록 .ng-valid, .ng-dirty와 같은 클래스 어트리뷰트를 자동으로 추가합니다.
Signal Forms는 이 클래스를 자동으로 추가하지 않습니다.
기존 동작을 유지하려면 NG_STATUS_CLASSES 프리셋을 제공하면 됩니다:
import {provideSignalFormsConfig} from '@angular/forms/signals';
import {NG_STATUS_CLASSES} from '@angular/forms/signals/compat';
import {bootstrapApplication} from '@angular/platform-browser';
bootstrapApplication(App, {
providers: [
provideSignalFormsConfig({
classes: NG_STATUS_CLASSES,
}),
],
});
직접 작성한 설정을 제공해서 원하는 로직에 따라 원하는 클래스를 적용할 수도 있습니다:
import {provideSignalFormsConfig} from '@angular/forms/signals';
import {bootstrapApplication} from '@angular/platform-browser';
bootstrapApplication(App, {
providers: [
provideSignalFormsConfig({
classes: {
'ng-valid': ({state}) => state().valid(),
'ng-invalid': ({state}) => state().invalid(),
'ng-touched': ({state}) => state().touched(),
'ng-dirty': ({state}) => state().dirty(),
},
}),
],
});
커스텀 컨트롤
커스텀 Signal Form 컨트롤은 반응형 폼과 템플릿 기반 폼에서도 그대로 사용할 수 있습니다. 따라서 기존 사용 코드를 깨뜨리지 않고 ControlValueAccessor 구현을 FormValueControl 또는 FormCheckboxControl로 전환할 수 있습니다.
중요: 같은 컴포넌트에 ControlValueAccessor와 FormValueControl/FormCheckboxControl을 함께 구현하지 마세요. 둘 중 하나만 구현하세요.
다음과 같은 커스텀 컨트롤이 있다고 가정해 봅시다:
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('');
}
이 커스텀 컨트롤은 기본 입력 엘리먼트나 ControlValueAccessor 기반 커스텀 컨트롤처럼 반응형 폼에서 사용할 수 있습니다. 다음은 반응형 폼을 사용하는 간단한 컴포넌트입니다:
import {Component} from '@angular/core';
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
import {BasicInput} from './basic-input';
@Component({
selector: 'app-example',
template: `
<form [formGroup]="reactiveFormGroup">
<app-basic-input formControlName="reactiveControlName" />
</form>
<p>Text: {{ reactiveFormGroup.value.reactiveControlName }}</p>
`,
imports: [ReactiveFormsModule, BasicInput],
})
export class ExampleComponent {
readonly reactiveFormGroup = new FormGroup({
reactiveControlName: new FormControl(''),
});
}
커스텀 app-basic-input 컨트롤의 변경 사항은 반응형 FormControl에 반영됩니다.