Angular Typed Forms を便利に使うスニペット
こんにちは、Angular GDEのlacolacoです。Angularアドベントカレンダー 25日目の記事は、Angular Typed Forms を便利に使うスニペットです。
Angular v14で導入された Typed Forms の恩恵をみなさんも日々受けていると思いますが、普段の開発の中で私がよく使うものや、最近作ってみたらけっこうよさそうだったものをまとめてみました。
SimpleControlValueAccessor<T>
現在のTyped Forms では FormControl<T>
や FormGroup<T>
などの Reactive FormsのフォームモデルAPIにジェネリクス型を指定することができますが、カスタムフォームコントロールを作るために使う ControlValueAccessor
にはジェネリクス型が指定できません。
ControlValueAccessor
インターフェースの実装はどのカスタムコントロールも似たりよったりの実装になりがちで、ボイラープレートのコードが多くなります。また、カスタムコントロールはアプリケーション特有のユースケースに対応することが多いですが、たいていの場合は単一の型の入出力に対応すれば十分です。
というわけで最近作ってみた SimpleControlValueAccessor<T>
抽象クラスを紹介します。
const noop = () => {};
@Directive()
export abstract class SimpleControlValueAccessor<T> implements ControlValueAccessor, OnDestroy {
protected onChange: (_: T) => void = noop;
protected onTouched: () => void = noop;
protected readonly ngControl = inject(NgControl, { optional: true });
private readonly onDestroy$ = new Subject<void>();
protected readonly takeUntilDestroyed = <T>() => pipe<Observable<T>, Observable<T>>(takeUntil(this.onDestroy$));
constructor() {
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
}
abstract setDisabledState(isDisabled: boolean): void;
abstract writeValue(value: T): void;
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
registerOnChange(fn: (_: T) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
}
だいたいのカスタムコントロールで共通になる registerOnChange
と registerOnTouched
の実装を抽象クラスにまとめています。また、ngOnDestroy
で Subject
を発行して takeUntil
で購読を解除するのに便利な takeUntilDestroyed
パイプも用意しています。
この抽象クラスを継承して ControlValueAccessor
を実装すると、次のようなコードになります。具象クラスでは writeValue
と setDisabledState
の実装だけを書けばよくなります。この例ではカスタムコントロールの内部で input
要素と FormControl
を使っていますが、ngModel
を使ってもいいですし、カスタムコントロールとしてどのようにユーザーとやり取りするかは自由です。
import { SimpleControlValueAccessor } from '../utitilites/forms';
@Component({
selector: 'custom-input',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<input
type="number"
min="0"
max="100"
required
[formControl]="formControl"
(click)="onTouched()"
/>
<div class="buttons">
<button (click)="onTouched(); setValue(100)" [disabled]="formControl.disabled">MAX</button>
<button (click)="onTouched(); setValue(0)" [disabled]="formControl.disabled">MIN</button>
</div>
`,
})
export class CustomInputComponent extends SimpleControlValueAccessor<number> {
readonly formControl = new FormControl(0, { nonNullable: true });
constructor() {
super();
// 内部FormControlの値が変わったら、親のFormControlに値を伝える
this.formControl.valueChanges.pipe(this.takeUntilDestroyed()).subscribe((value) => {
this.onChange(value);
});
}
// 親のFormControlの値が変わったら、内部FormControlに値を伝える
override writeValue(value: number): void {
this.formControl.setValue(value, { emitEvent: false });
}
override setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.formControl.disable();
} else {
this.formControl.enable();
}
}
setValue(value: number): void {
this.formControl.setValue(value);
}
}
getValidValueChanges()
Typed Forms になってから、 AbstractControl<T>
の valueChanges
プロパティも Observable<T>
型になって使いやすくなりました。このスニペットはあまりジェネリクスとは関係ないですが、フォームモデルが VALID
なときだけ valueChanges
を流す Observable
を作る関数です。
export function getValidValueChanges<T>(control: AbstractControl<T>): Observable<T> {
return combineLatest([control.valueChanges, control.statusChanges]).pipe(
filter(([, status]) => status === 'VALID'),
map(([value]) => value),
);
}
よくある実装では valueChanges
を購読したコールバック関数の中で control.valid
を確認しますが、 valueChanges
に値が流れるタイミングでは control.valid
が更新されていないことがあるため、確実に VALID
なときだけ値を取得したい場合には control.updateValueAndValidity()
を呼び出す必要があります。
このスニペットでは statusChanges
と valueChanges
を combineLatest
しているため、statusChanges
が VALID
になったタイミングで valueChanges
が流れるようになっています。そのためコールバック関数の中で control.valid
を確認する必要はなくなります。
getValidValueChanges(this.formControl).pipe(takeUntil(this.onDestroy$)).subscribe((value) => {
// 常にフォームモデルが VALID なときだけ値が流れる
});
zod
を使ったフォームモデルのバリデーション
zod はTypeScriptととても相性がいいバリデーションライブラリです。このスニペットは zod
の API をカスタムバリデータ関数の中で使うものです。特定の型に対応したフォームコントロールとバリデータは、フォームコントロールの生成関数に隠蔽してしまうのが使いやすいです。
// スキーマ定義
import { z } from 'zod';
export const Age = z.number().int().positive().max(100).brand('Age');
export type Age = z.infer<typeof Age>;
// カスタムコントロール
export function createAgeControl(defaultValue: Age, opts: FormControlOptions = {}): FormControl<Age> {
return new FormControl(defaultValue, {
...opts,
validators: [
...(opts.validators ? (Array.isArray(opts.validators) ? opts.validators : [opts.validators]) : []),
// カスタムバリデータ
(control: AbstractControl) => {
// zod のスキーマ定義から使ってバリデーションする
const value = Age.safeParse(control.value);
if (!value.success) {
// zod のエラーコードでバリデーションエラーを返す
return Object.fromEntries(value.error.issues.map((issue) => [issue.code, issue.message]));
}
return null;
},
],
nonNullable: true,
});
}
もっと抽象化して zod で定義されたスキーマに汎用的に使えるようにジェネリクスを使うとこんな感じにもできます。
export function zodTypeValidator<T extends z.ZodType>(zodType: T): ValidatorFn {
return (control: AbstractControl) => {
const value = zodType.safeParse(control.value);
if (!value.success) {
return {
type_error: value.error.message,
};
}
return null;
};
}
export function createZodTypeControl<T extends z.ZodType>(
zodType: T,
defaultValue: z.infer<T>,
opts: FormControlOptions = {},
): FormControl<z.infer<T>> {
return new FormControl(defaultValue, {
...opts,
validators: [
...(opts.validators ? (Array.isArray(opts.validators) ? opts.validators : [opts.validators]) : []),
zodTypeValidator(zodType),
],
nonNullable: true,
});
}
const ageFormControl: FormControl<Age> = createZodTypeControl(Age, Age.parse(20), {
validators: [Validators.required],
});
値の詳細なバリデーションをオブジェクトのスキーマ定義に任せられるので、フォームモデル周辺のUIロジックから関心が減ってコンポーネントがすっきりします。
2023年もよろしくお願いします
来年も引き続きAngular GDEとして日本のAngularコミュニティをサポートしていきます。みんながAngularを使って開発する時間が少しでも楽しいものになるようにあれこれやっていきます。
コミュニティの盛り上がりというのは誰かひとりが頑張っても作れるものではなく、参加者ひとりひとりのちょっとした貢献の積み重ねですから(このアドベントカレンダーもそう!)、ぜひともコミュニティのみなさんと一緒に盛り上がりを作っていきたいです。来年もよろしくお願いします!
- Angular 日本語ドキュメンテーション: https://angular.jp/
- Angular 日本ユーザー会: https://community.angular.jp/
- Angular 日本ユーザー会 Discord: http://join-discord.angular.jp/
Discussion