시그널 폼의 필드 상태를 확인하면 사용자가 입력한 내용이 맞게 입력되었는지(valid, invalid, errors), 사용자가 상호작용을 했는지(touched, dirty), 필드를 사용할 수 있는지(disabled, hidden) 확인할 수 있습니다.
필드 상태 이해하기
form() 함수로 폼을 생성하면 폼 모델을 객체 형태로 반영하는 필드 트리(field tree) 를 반환합니다.
그러면 이 트리의 개별 필드를 form.email와 같이 객체 참조 방식으로 접근할 수 있습니다.
필드 상태에 접근하기
필드 트리에 있는 개별 필드는 모두 form.email() 같은 방식으로 실행할 수 있는데, 이렇게 실행하면 필드의 유효성, 사용자의 접근 기록, 사용할 수 있는 상태 등을 반응형 시그널로 확인할 수 있는 FieldState 객체를 반환합니다.
예를 들면, invalid() 시그널은 필드 유효성 검사 상태를 표현합니다:
import {Component, signal} from '@angular/core';
import {form, FormField, required, email} from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<input type="email" [formField]="registrationForm.email" />
@if (registrationForm.email().invalid()) {
<p class="error">Email has validation errors:</p>
<ul>
@for (error of registrationForm.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
`,
})
export class Registration {
registrationModel = signal({
email: '',
password: '',
});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.email, {message: 'Email is required'});
email(schemaPath.email, {message: 'Enter a valid email address'});
});
}
이 예제를 보면, 템플릿에서 오류 메시지를 표시하기 위해 registrationForm.email().invalid()를 확인하는 것을 확인할 수 있습니다.
필드 상태 시그널
가장 많이 사용하는 시그널은 필드의 현재값을 반환하는 WritableSignal 타입의 value() 입니다:
const emailValue = registrationForm.email().value();
console.log(emailValue); // 현재 email 문자열
필드 상태에는 value() 외에도 유효성 검사, 사용자 상호작용 추적, 가용성 제어를 위한 시그널이 있습니다:
| 분류 | 시그널 | 의미 |
|---|---|---|
| 유효성 검사 | valid() |
진행 중인 검사 없이 모든 필드가 유효성 검사를 통과했습니다. |
invalid() |
유효성 검사 결과 오류가 발생했습니다. | |
errors() |
유효성 검사를 통과하지 못한 오류 목록 | |
pending() |
비동기 유효성 검사가 진행 중인지 | |
| 사용자 상호작용 | touched() |
사용자가 필드에 접근했다가 다른 곳으로 이동했습니다. |
dirty() |
사용자가 필드 값을 수정했습니다. | |
| 비활성화 상태 | disabled() |
필드가 비활성화되어, 전체 폼에 영향을 주지 않습니다. |
hidden() |
필드가 숨겨져 있어야 합니다. 템플릿에서 @if를 사용해서 폼 컨트롤을 표시하지 않을 수 있습니다. |
|
readonly() |
필드가 읽기전용이며, 전체 폼에 영향을 주지 않습니다. |
이 시그널들을 활용하면 사용자의 상호작용에 반응하는 방식으로 반응형 폼을 구성할 수 있습니다. 개별 항목을 자세하게 알아봅시다.
유효성 검사 상태
유효성 검사 상태를 표현하는 시그널은 개별 필드가 유효한지, 오류가 발생했는지 의미합니다.
참고: 이 문서는 템플릿과 컴포넌트 로직에서 valid(), invalid(), errors()와 같은 유효성 검사 상태로 활용하는 방법을 안내합니다.
유효성 검사 규칙을 정의하고 커스텀 유효성 검사기를 활용하는 방법은 유효성 검사 문서를 참고하세요.
검사 결과 확인하기
valid()와 invalid()를 확인하면 유효성 검사 결과를 확인할 수 있습니다:
@Component({
template: `
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().invalid()) {
<p class="error">Email is invalid</p>
}
@if (loginForm.email().valid()) {
<p class="success">Email looks good</p>
}
`,
})
export class Login {
loginModel = signal({email: '', password: ''});
loginForm = form(this.loginModel);
}
| 시그널 | true를 반환하는 경우 |
|---|---|
valid() |
진행 중인 검사 없이 필드가 유효성 검사 규칙을 통과한 경우 |
invalid() |
유효성 검사 오류가 발생한 경우 |
컴포넌트 코드에서 유효성 검사를 확인할 때 "오류 있음" 과 "유효성 검사 진행 중"을 구분하려면 !valid() 대신 invalid()를 활용하세요.
비동기 유효성 검사가 아직 완료되지 않아 필드가 아직 유효하지 않은 상태라면 valid()와 invalid()가 모두 false 이기 때문입니다.
이 상태는 오류가 아직 확인되지 않은 상태일 수 있습니다.
유효성 검사 오류 확인하기
errors()로 유효성 검사에서 발생한 오류 목록을 확인하거나, getError()로 개별 오류를 확인할 수 있습니다.
개별 오류 객체에는 이런 필드가 존재합니다:
| 프로퍼티 | 설명 |
|---|---|
kind |
"required"나 "email" 같이 실패한 유효성 검사 이름 |
message |
(생략 가능) 사람이 읽을 오류 메시지 |
fieldTree |
오류가 발생한 FieldTree 참조 |
참고: message 프로퍼티는 생략할 수 있습니다.
유효성 검사 함수도 커스텀 오류 메시지를 표시할 수 있지만, 이 프로퍼티를 지정하지 않은 경우라면 kind 값으로 오류 메시지를 매칭시켜야 합니다.
템플릿에서 오류를 표시하는 예제 코드를 확인해 봅시다:
@Component({
template: `
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().errors().length > 0) {
<div class="errors">
@for (error of loginForm.email().errors(); track error) {
<p>{{ error.message }}</p>
}
</div>
}
`
})
이 코드는 필드에 발생한 모든 오류 메시지를 표시합니다.
getError()로 템플릿에 오류를 표시할 수도 있습니다:
@Component({
template: `
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().getError('required'); as error) {
<div class="errors">
<p>{{ error.message }}</p>
</div>
}
`
})
getError()는 오류의 kind로 어떤 문자열이든 받을 수 있으며, "required", "email", "minLength" 같은 내장 유효성 검사 오류 종류를 인식합니다.
유효성 검사 진행 상태
pending() 시그널을 확인하면 비동기 유효성 검사가 진행 중인지 확인할 수 있습니다:
@Component({
template: `
<input type="email" [formField]="signupForm.email" />
@if (signupForm.email().pending()) {
<p>Checking if email is available...</p>
}
@if (signupForm.email().invalid() && !signupForm.email().pending()) {
<p>Email is already taken</p>
}
`
})
이 시그널은 비동기 유효성 검사가 실행 중일 때 로딩 상태를 표시하는 용도로 활용할 수 있습니다.
사용자 상호작용 상태
사용자가 필드에 접근한 이후에만 오류를 표시하려면, 사용자가 필드에 접근했었는지 확인해야 합니다.
touched 상태
touched() 시그널은 사용자가 필드에 접근했다가 다른 곳으로 이동했는지, 또는 코드에서 필드를 touched 상태로 표시했는지 추적합니다. 상호작용할 수 있는 필드만 touched 상태가 될 수 있습니다. 숨겨진 필드, 비활성화된 필드, 읽기 전용 필드는 사용자 상호작용이나 markAsTouched() 호출로 touched 상태가 되지 않습니다.
폼의 특정 영역에서 동작을 실행할 때 그 영역의 유효성 검사 오류를 표시하려면 해당 영역의 필드에 markAsTouched()를 호출하세요. skipDescendants의 기본값은 false이므로 해당 필드와 모든 하위 필드를 touched 상태로 표시합니다.
예를 들어 결제 과정에서 다음 단계로 진행하기 전에 배송 정보 영역을 검사할 수 있습니다:
import {Component, signal} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
@Component({
selector: 'app-checkout-shipping',
imports: [FormField],
template: `
<label>
Name
<input [formField]="checkoutForm.shipping.name" />
</label>
@if (checkoutForm.shipping.name().touched() && checkoutForm.shipping.name().invalid()) {
<p>{{ checkoutForm.shipping.name().errors()[0].message }}</p>
}
<label>
Address
<input [formField]="checkoutForm.shipping.address" />
</label>
@if (checkoutForm.shipping.address().touched() && checkoutForm.shipping.address().invalid()) {
<p>{{ checkoutForm.shipping.address().errors()[0].message }}</p>
}
<button type="button" (click)="continueToPayment()">Continue</button>
@if (showPayment() && checkoutForm.shipping().valid()) {
<p>Ready for payment.</p>
}
`,
})
export class CheckoutShipping {
checkoutModel = signal({
shipping: {
name: '',
address: '',
},
});
showPayment = signal(false);
checkoutForm = form(this.checkoutModel, (schemaPath) => {
required(schemaPath.shipping.name, {message: 'Enter a name'});
required(schemaPath.shipping.address, {message: 'Enter an address'});
});
continueToPayment() {
this.checkoutForm.shipping().markAsTouched();
if (this.checkoutForm.shipping().invalid()) {
return;
}
this.showPayment.set(true);
}
}
continueToPayment()가 checkoutForm.shipping()에 markAsTouched()를 호출하면 기본값인 skipDescendants: false가 적용됩니다. Angular가 shipping, shipping.name, shipping.address를 touched 상태로 표시하므로 전체 폼을 제출하기 전에도 하위 필드의 touched() && invalid() 조건에 따라 오류 메시지가 표시됩니다.
참고: 하위 필드의 touched 상태는 유지하고 호출 대상 필드만 touched 상태로 표시하려는 경우에만 {skipDescendants: true}를 전달하세요.
dirty 상태
폼은 데이터가 실제로 변경되었는지 확인해야 하는 경우가 많습니다. 저장하지 않은 변경사항이 있다고 사용자에게 알리거나, 필요할 때만 저장 버튼을 활성화하는 식입니다.
dirty() 시그널은 사용자가 필드의 값을 변경했을 때 true 를 전달하며, 이후에 다시 변경되어 초기값과 같아져도 true 상태로 남습니다:
@Component({
template: `
<form novalidate>
<input [formField]="profileForm.name" />
<input [formField]="profileForm.bio" />
@if (profileForm().dirty()) {
<p class="warning">You have unsaved changes</p>
}
</form>
`,
})
export class Profile {
profileModel = signal({name: 'Alice', bio: 'Developer'});
profileForm = form(this.profileModel);
}
저장하지 않은 변경사항을 사용자에게 알리거나 데이터가 실제로 변경되었을 때만 저장 버튼을 활성화하려면 dirty() 시그널을 활용하세요.
touched vs dirty
이 시그널들은 서로 다른 종류의 상호작용 상태를 추적합니다:
| 시그널 | true가 되는 경우 |
|---|---|
touched() |
사용자가 상호작용 가능한 필드에 접근했다가 이동했거나, 코드에서 touched 상태로 표시했을 때. |
dirty() |
사용자가 필드 값을 변경했을 때. 포커스를 다른 곳으로 옮기지 않아도, 초기값으로 다시 바꿔도 남아있습니다. |
시그널은 여러 가지로 조합할 수 있습니다:
| 상태 | 시나리오 |
|---|---|
touched 활성화, dirty 비활성화 |
사용자가 필드에 접근했지만 아무것도 수정하지 않았습니다. |
touched 활성화, dirty 활성화 |
사용자가 필드에 접근했고 값을 바꾼 후에 포커스를 다른 곳으로 옮겼습니다. |
참고: 화면에 표시되지 않거나, 비활성화되었거나, 읽기 전용인 필드는 사용자와 상호작용할 수 없기 때문에, touched, dirty 상태가 되지 않습니다.
가용성 상태
가용성 상태 시그널은 사용자가 필드와 상호작용할 수 있는지, 값을 수정할 수 있는지, 화면에 표시되는지 표현합니다.
비활성화되었거나, 숨겨져 있거나, 읽기 전용인 필드는 사용자와 상호작용할 수 없습니다.
그래서 부모 폼의 유효성 상태, touched, dirty 상태에 영향을 주지 않습니다.
비활성화된 필드
disabled() 시그널은 필드가 사용자의 입력을 받을 수 있는 상태인지 표현합니다.
비활성화된 필드는 화면에 표시되지만 조작할 수 없습니다.
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
// 팁: `[formField]` 디렉티브가 `disabled()` 상태에 따라 엘리먼트의 `disabled` 어트리뷰트를 자동으로 바인딩합니다.
// `[disabled]="field().disabled()"` 라고 지정할 필요가 없습니다.
template: `
<input [formField]="orderForm.couponCode" />
@if (orderForm.couponCode().disabled()) {
<p class="info">Coupon code is only available for orders over $50</p>
}
`,
})
export class Order {
orderModel = signal({
total: 25,
couponCode: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {when: ({valueOf}) => valueOf(schemaPath.total) < 50});
});
}
위 예제 코드는 valueOf(schemaPath.total)을 사용해서 total 필드 값에 따라 couponCode가 비활성화될지 결정합니다.
참고: schemaPath와 같이 스키마 콜백 함수의 인자는 폼 전체 필드에 접근할 수 있는 SchemaPathTree 객체입니다.
변수 이름은 원하는 대로 지정할 수 있습니다.
disabled(), hidden(), readonly()와 같은 규칙을 정의할 때 when 함수는 일반적으로 구조 분해된 FieldContext 객체를 인자로 받습니다.
이 객체에서 자주 사용되는 메서드가 있습니다:
valueOf(schemaPath.otherField)- 폼 안에 있는 다른 필드의 값을 읽습니다.value()- 필드의 값을 시그널로 전달합니다.
비활성화된 필드는 부모 폼의 유효성 검사 상태에 영향을 주지 않습니다.
비활성화된 필드에서 유효성 검사가 실패하더라도, 부모 폼은 유효할 수 있습니다.
disabled() 상태는 사용자의 상호작용 이력, 유효성 검사 결과에는 영향을 주지만, 필드 값에는 영향을 주지 않습니다.
숨겨진 필드
hidden() 시그널은 필드가 표시되는지를 표현합니다.
hidden() 시그널의 상태에 따라 @if로 필드를 화면에 표시하거나 감출 수 있습니다:
import {Component, signal} from '@angular/core';
import {form, FormField, hidden} from '@angular/forms/signals';
@Component({
selector: 'app-profile',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="profileForm.isPublic" />
Make profile public
</label>
@if (!profileForm.publicUrl().hidden()) {
<label>
Public URL
<input [formField]="profileForm.publicUrl" />
</label>
}
`,
})
export class Profile {
profileModel = signal({
isPublic: false,
publicUrl: '',
});
profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, {when: ({valueOf}) => !valueOf(schemaPath.isPublic)});
});
}
숨겨진 필드는 전체 폼 유효성 검사에 영향을 주지 않습니다.
필수 항목이 감춰져 있어도 폼 제출이 막히지 않습니다.
hidden() 상태는 필드의 가용성과 유효성 검사에 영향을 주지만, 필드의 값에는 영향을 주지 않습니다.
읽기 전용 필드
readonly() 시그널은 필드가 읽기 전용인지 표현합니다.
읽기 전용 필드는 값을 화면에 표시하지만 사용자가 수정할 수 없습니다:
import {Component, signal} from '@angular/core';
import {form, FormField, readonly} from '@angular/forms/signals';
@Component({
selector: 'app-account',
imports: [FormField],
template: `
<label>
Username (cannot be changed)
<input [formField]="accountForm.username" />
</label>
<label>
Email
<input [formField]="accountForm.email" />
</label>
`,
})
export class Account {
accountModel = signal({
username: 'johndoe',
email: 'john@example.com',
});
accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username);
});
}
참고: [formField] 디렉티브는 readonly() 상태에 따라 필드의 readonly 어트리뷰트를 자동으로 바인딩합니다.
[readonly]="field().readonly()"와 같은 로직을 작성할 필요가 없습니다.
비활성화된 필드나 감춰진 필드와 마찬가지로, 읽기 전용 필드는 사용자와 상호작용할 수 없으며, 부모 폼 상태에도 영향을 주지 않습니다.
readonly() 상태는 수정 가능 여부와 유효성 검사에 영향을 주지만, 필드의 값은 변경하지 않습니다.
언제 사용해야 할까요
| 시그널 | 사용하는 경우 | 사용자가 볼 수 있는지 | 사용자가 상호작용할 수 있는지 | 유효성 검사에 영향을 주는지 |
|---|---|---|---|---|
disabled() |
필드를 일시적으로 사용할 수 없을 때 (예: 다른 필드 값에 따라) | O | X | X |
hidden() |
현재 맥락에서 필드가 필요하지 않을 때 | X (@if를 사용한 경우) |
X | X |
readonly() |
값을 화면에 표시하지만 수정하지 못하게 할 때 | O | X | X |
폼 계층 상태
최상위 폼도 필드 트리의 필드로 간주됩니다.
그래서 최상위 폼을 함수처럼 실행하면 전체 자식 폼 상태에 접근할 수 있는 FieldState 객체를 반환합니다.
폼 상태에 접근하기
@Component({
template: `
<form novalidate>
<input [formField]="loginForm.email" />
<input [formField]="loginForm.password" />
<button [disabled]="!loginForm().valid()">Sign In</button>
</form>
`,
})
export class Login {
loginModel = signal({email: '', password: ''});
loginForm = form(this.loginModel);
}
이 예시에서는, 전체 자식 필드가 유효성 검사를 통과해야 전체 폼이 유효합니다. 이 방식을 활용하면 전체 폼의 유효성 검사에 따라 폼 제출 버튼의 활성화/비활성화 상태를 변경할 수 있습니다.
폼 계층 시그널
최상위 폼도 필드로 간주하기 때문에 valid(), invalid(), touched(), dirty() 과 같은 시그널이 동일하게 존재합니다.
| 시그널 | 폼 계층에서는 |
|---|---|
valid() |
상호작용할 수 있는 필드가 모두 유효하고, 진행 중인 유효성 검사도 없습니다. |
invalid() |
1개 이상 필드에 유효성 검사 오류가 발생했습니다. |
pending() |
1개 이상 필드에 유효성 검사가 진행 중입니다. |
touched() |
폼 자체나 상호작용할 수 있는 하위 필드 중 하나 이상이 touched 상태입니다. |
dirty() |
사용자가 어떠한 필드라도 값을 변경했습니다. |
폼 계층 vs 필드 계층
폼 계층 상태는 이런 경우 사용합니다:
- 폼 제출 버튼을 활성화/비활성화할 때
- "저장" 버튼의 상태를 조작할 때
- 전체 폼의 유효성을 검사할 때
- 저장하지 않은 변동사항이 있다고 알릴 때
필드 계층 상태는 이런 경우 사용합니다:
- 개별 필드의 오류 메시지를 표시할 때
- 개별 필드의 스타일을 지정할 때
- 개별 필드의 유효성 결과를 활용할 때
- 필드의 구조를 조건에 따라 변경할 때
상태 전파
필드 상태는 자식 필드에서 부모 필드로 전파되며, 결국 최상위 폼 단위까지 전파됩니다.
자식 필드의 상태가 부모 필드에 어떻게 영향을 미칠까요
자식 필드가 유효하지 않으면 그 부모 필드 그룹이 유효하지 않게 되며, 최상위 폼까지 유효하지 않은 상태가 됩니다.
그리고 자식 필드가 touched 상태가 되거나 dirty 상태가 되면 부모 필드 그룹에도 반영됩니다.
이런 전파 기능을 통해 개별 폼이나 전체 폼에서 유효성을 확인할 수 있습니다.
const userModel = signal({
profile: {
firstName: '',
lastName: '',
},
address: {
street: '',
city: '',
},
});
const userForm = form(userModel);
// firstName이 유효하지 않으면 profile도 유효하지 않습니다.
userForm.profile.firstName().invalid() === true;
// → userForm.profile().invalid() === true
// → userForm().invalid() === true
hidden, disabled, readonly
숨겨진 필드, 비활성화된 필드, 읽기전용 필드는 사용자와 상호작용할 수 없기 때문에 부모 폼 상태에 영향을 주지 않습니다:
const orderModel = signal({
customerName: '',
requiresShipping: false,
shippingAddress: '',
});
const orderForm = form(orderModel, (schemaPath) => {
hidden(schemaPath.shippingAddress, {when: ({valueOf}) => !valueOf(schemaPath.requiresShipping)});
});
이 예제에서 shippingAddress가 화면에서 숨겨지면 폼 유효성 상태에 영향을 주지 않습니다.
따라서 shippingAddress가 필수 입력 항목이지만 빈 값이어도 폼은 유효한 상태가 됩니다.
숨겨진 필드, 비활성화된 필드, 읽기 전용 필드는 유효성 검사, 사용자가 접근했거나 값을 수정했는지 여부에 따라 폼 제출을 막는 경우에 영향을 주지 않습니다.
템플릿에서 상태 활용하기
필드 상태 시그널은 Angular 템플릿과 경계없이 통합되어, 별도 이벤트 처리 없이도 반응형 폼 경험을 제공합니다.
조건부 오류 표시
오류는 사용자가 상호작용한 후에만 표시하는 것이 좋습니다:
import {Component, signal} from '@angular/core';
import {form, FormField, email} from '@angular/forms/signals';
@Component({
selector: 'app-signup',
imports: [FormField],
template: `
<label>
Email
<input type="email" [formField]="signupForm.email" />
</label>
@if (signupForm.email().touched() && signupForm.email().invalid()) {
<p class="error">{{ signupForm.email().errors()[0].message }}</p>
}
`,
})
export class Signup {
signupModel = signal({email: '', password: ''});
signupForm = form(this.signupModel, (schemaPath) => {
email(schemaPath.email);
});
}
이 방식을 활용하면 사용자가 아직 필드를 조작하기 전에 오류가 표시되는 것을 방지할 수 있습니다. 오류 메시지는 사용자가 필드에 포커스를 두었다가 다른 곳으로 이동한 뒤에만 표시됩니다.
조건부 필드 활성화
hidden() 시그널과 @if 시그널을 함께 활용하면 조건에 따라 필드를 화면에 표시하거나 숨길 수 있습니다:
import {Component, signal} from '@angular/core';
import {form, FormField, hidden} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="orderForm.requiresShipping" />
Requires shipping
</label>
@if (!orderForm.shippingAddress().hidden()) {
<label>
Shipping Address
<input [formField]="orderForm.shippingAddress" />
</label>
}
`,
})
export class Order {
orderModel = signal({
requiresShipping: false,
shippingAddress: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
hidden(schemaPath.shippingAddress, {
when: ({valueOf}) => !valueOf(schemaPath.requiresShipping),
});
});
}
화면에서 감춰진 필드는 유효성 검사에 영향을 주지 않기 때문에, 이 필드가 유효하지 않은 경우에도 폼 제출은 영향을 받지 않습니다.
배열 필드의 값 추적하기
시그널 폼에서 필드 목록을 반복하는 @for 블록은 필드의 동일성을 기준으로 항목을 추적해야 합니다.
@Component({
imports: [FormField],
template: `
@for (field of form.emails; track field) {
<input [formField]="field" />
}
`,
})
export class App {
formModel = signal({emails: ['john.doe@mail.com', 'max.musterman@mail.com']});
form = form(this.formModel);
}
폼 시스템은 배열 안의 모델 값을 추적하면서 자동으로 생성한 필드의 동일성을 안정적으로 유지합니다. 항목이 변경되면 일부 프로퍼티가 같더라도 새로운 논리적 항목일 수 있습니다. 동일성을 기준으로 추적하면 프레임워크는 기존 UI 엘리먼트를 재사용하는 대신 이런 항목을 별개로 처리합니다. 이렇게 하면 폼 입력처럼 상태를 가진 엘리먼트가 잘못 공유되는 것을 방지하고, 바인딩을 모델의 올바른 부분에 연결해 유지할 수 있습니다.
컴포넌트 로직에서 필드 상태 활용하기
필드 상태 시그널은 Angular가 제공하는 computed(), effect()와 반응형으로 연동할 수 있습니다.
폼 제출 전 유효성 검사
컴포넌트 메서드에서 폼 유효성을 검사해 봅시다:
export class Registration {
registrationModel = signal({
username: '',
email: '',
password: '',
});
registrationForm = form(this.registrationModel);
async onSubmit() {
// 비동기 유효성 검사가 완료될 때까지 기다립니다.
if (this.registrationForm().pending()) {
console.log('Waiting for validation...');
return;
}
// 유효하지 않은 상태에서 폼 제출을 방지합니다.
if (this.registrationForm().invalid()) {
console.error('Form is invalid');
return;
}
const data = this.registrationModel();
await this.api.register(data);
}
}
이제 폼은 유효할 때만 제출할 수 있습니다.
computed() 파생 상태
필드 상태와 연동되는 시그널을 만들면, 필드 상태에 따라 자동으로 실행되는 로직을 만들 수 있습니다:
export class Password {
passwordModel = signal({password: '', confirmPassword: ''});
passwordForm = form(this.passwordModel);
// 패스워드의 복잡도를 표시합니다.
passwordStrength = computed(() => {
const password = this.passwordForm.password().value();
if (password.length < 8) return 'weak';
if (password.length < 12) return 'medium';
return 'strong';
});
// 필수 항목이 모두 입력되었는지 확인합니다.
allFieldsFilled = computed(() => {
return (
this.passwordForm.password().value().length > 0 &&
this.passwordForm.confirmPassword().value().length > 0
);
});
}
로직으로 상태 변경
필드의 상태는 일반적으로 사용자와 상호작용하면서 변경되지만, 때로는 로직으로 조작하는 경우도 있습니다. 폼을 제출한 후에 초기화하는 것이 일반적입니다.
폼 제출
시그널 폼은 폼 제출을 간단하게 구현하기 위해 FormRoot 디렉티브를 제공합니다.
이 디렉티브는 브라우저의 기본 폼 제출 동작을 막고 <form> 엘리먼트에 novalidate 어트리뷰트를 추가합니다.
import {FormField, FormRoot, form, required, email} from '@angular/forms/signals';
import {Component, signal} from '@angular/core';
@Component({
imports: [FormRoot, FormField],
template: `
<form [formRoot]="registrationForm">
<input [formField]="registrationForm.username" />
<input type="email" [formField]="registrationForm.email" />
<input type="password" [formField]="registrationForm.password" />
<button type="submit">Register</button>
</form>
`,
})
export class Registration {
registrationModel = signal({username: '', email: '', password: ''});
registrationForm = form(
this.registrationModel,
(schemaPath) => {
required(schemaPath.username);
email(schemaPath.email);
required(schemaPath.password);
},
{
submission: {
action: async () => this.submitToServer(),
},
},
);
private submitToServer() {
// 데이터를 서버로 보냅니다.
}
}
formRoot를 사용하면 폼을 제출할 때 submit() 함수가 자동으로 실행됩니다.
이 함수는 모든 필드를 touched 상태로 표시하여 유효성 검사 오류가 드러나게 하고, 폼이 유효한 경우 action 콜백 함수를 실행합니다.
디렉티브를 사용하지 않고 submit(this.registrationForm)을 실행해서 수동으로 폼을 제출할 수도 있습니다.
submit() 함수를 명시적으로 호출할 때는 FormSubmitOptions를 전달해서 폼의 기본 제출 로직을 재정의할 수 있습니다: submit(this.registrationForm, {action: () => /* ... */ })
폼 제출 후 폼 초기화
폼을 제출하고 나면 사용자가 상호작용했던 기록과 필드 값을 모두 초기화하는 것이 일반적입니다.
이때 reset() 메서드를 실행하면 touched 플래그와 dirty 플래그를 초기화합니다.
reset() 메서드를 실행하면서 새로운 값으로 폼을 설정하려면 인자로 전달하면 됩니다:
export class Contact {
private readonly INITIAL_MODEL = {name: '', email: '', message: ''};
contactModel = signal({...this.INITIAL_MODEL});
contactForm = form(this.contactModel, {
submission: {
action: async (f) => {
await this.api.sendMessage(this.contactModel());
// touched, dirty 등 상호작용 기록을 초기화하고 초기값으로 돌립니다.
f().reset({...this.INITIAL_MODEL});
},
},
});
}
이렇게 하면 폼의 오류 메시지와 dirty 상태가 초기화되며 새로운 값을 입력받을 준비가 됩니다.
유효성 검사 상태에 따라 스타일 지정하기
유효성 검사 결과에 따라 CSS 클래스를 바인딩하면 폼에 커스텀 스타일을 지정할 수 있습니다:
import {Component, signal} from '@angular/core';
import {form, FormField, email} from '@angular/forms/signals';
@Component({
imports: [FormField],
template: `
<input
type="email"
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
`,
styles: `
input.is-invalid {
border: 2px solid red;
background-color: white;
}
input.is-valid {
border: 2px solid green;
}
`,
})
export class StyleExample {
model = signal({email: ''});
form = form(this.model, (schemaPath) => {
email(schemaPath.email);
});
}
touched()와 유효성 검사 상태를 확인하면 사용자가 상호작용한 후에 오류 스타일을 지정할 수 있습니다.
필드에 바인딩된 폼 컨트롤에 포커스 이동하기
Angular Signal Forms의 필드 상태는 focusBoundControl() 메서드를 제공합니다. 이 메서드로 필드에 연결된 폼 컨트롤에 포커스를 이동할 수 있습니다.
대표적인 활용 방법은 폼 제출 시 접근성을 개선하는 것입니다. 폼이 유효하지 않을 때 오류 메시지를 표시하고 첫 번째 오류 필드로 포커스를 자동 이동하여 사용자가 수정할 수 있도록 안내합니다.
기본 사용법
다음과 같은 회원가입 폼이 있다고 가정해 봅시다:
@Component({/* ... */})
export class Registration {
registrationModel = signal({username: '', email: '', password: ''});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.username);
email(schemaPath.email);
required(schemaPath.password);
});
}
email 필드에 연결된 컨트롤로 포커스를 이동하려면 다음과 같이 호출합니다:
registrationForm.email().focusBoundControl();
스크롤 방지하기
컨트롤이 화면 밖에 있을 때 스크롤하지 않고 포커스만 이동하려면 focusBoundControl()을 호출할 때 preventScroll 옵션을 true로 설정하세요.
registrationForm.email().focusBoundControl({preventScroll: true});
제출 시 첫 번째 오류 필드에 포커스 이동하기
사용자가 오류가 있는 폼을 제출하면 errorSummary()로 첫 번째 오류 필드를 찾아 포커스를 이동하세요:
onSubmit() {
const firstError = this.registrationForm().errorSummary()[0];
if (firstError?.fieldTree) {
firstError.fieldTree().focusBoundControl();
} else {
// 폼 제출을 진행합니다.
}
}
커스텀 컨트롤
커스텀 컨트롤에서 focusBoundControl()을 호출해도 기본적으로는 아무 동작도 하지 않습니다. 커스텀 컨트롤에는 기본 입력 엘리먼트가 여러 개 있을 수 있기 때문입니다. 예를 들어 날짜 선택기는 일, 월, 연도 필드를 각각 포함할 수 있습니다. Angular는 어느 엘리먼트에 포커스를 주거나 어떤 동작을 실행해야 하는지 결정할 수 없습니다.
커스텀 컨트롤에서 코드로 포커스를 이동할 수 있도록 하려면 focus() 메서드를 구현하세요. 연결된 필드 상태에 focusBoundControl()을 호출하면 Angular가 컨트롤에 구현된 focus() 메서드를 호출합니다.
다음은 커스텀 비밀번호 입력의 예입니다:
<div class="password-block">
<input type="password" #passwordCtrl [value]="value()" (input)="value.set($event.target.value)" />
</div>
@Component({/* ... */})
export class PasswordInput implements FormValueControl<string> {
readonly value = model<string>('');
readonly passwordCtrl = viewChild.required<ElementRef<HTMLInputElement>>('passwordCtrl');
// 이 커스텀 컨트롤에 연결된 필드 상태에서
// focusBoundControl()을 호출하면 자동으로 실행됩니다.
focus(): void {
this.passwordCtrl().nativeElement.focus();
}
}
다음 단계
이번 문서는 유효성 검사, 가용성 상태 처리, 사용자의 상호작용을 추적하고 필드 상태가 전파되는 것을 다뤘습니다. 시그널 폼의 이런 내용도 확인해 보세요: