Complete Post Form — Full BlogApp Create and Edit Form

The BlogApp’s post create/edit form is the most complex form in the application — it combines all reactive form techniques: a shared form definition for create and edit modes, auto-generated slug with async uniqueness validation, a dynamic tags FormArray, server-side error mapping, unsaved changes detection, and two distinct actions (save draft vs publish). Building it correctly demonstrates how all the reactive form patterns from this chapter work together in a real feature.

Complete PostFormComponent

@Component({
  selector:   'app-post-form',
  standalone:  true,
  imports:    [ReactiveFormsModule, RouterLink, MatButtonModule,
               MatFormFieldModule, MatInputModule, MatSelectModule],
  template: `
    <form [formGroup]="form" novalidate>
      <mat-form-field appearance="outline" class="full-width">
        <mat-label>Title</mat-label>
        <input matInput formControlName="title">
        <mat-error *ngIf="form.controls.title.hasError('required')">Required</mat-error>
        <mat-error *ngIf="form.controls.title.hasError('minlength')">
          At least 5 characters
        </mat-error>
      </mat-form-field>

      <mat-form-field appearance="outline" class="full-width">
        <mat-label>Slug</mat-label>
        <input matInput formControlName="slug">
        @if (form.controls.slug.pending) {
          <mat-hint>Checking availability...</mat-hint>
        }
        <mat-error *ngIf="form.controls.slug.hasError('slugFormat')">
          Use lowercase, numbers, hyphens only
        </mat-error>
        <mat-error *ngIf="form.controls.slug.hasError('slugTaken')">
          Slug already in use
        </mat-error>
      </mat-form-field>

      <div formArrayName="tags" class="tags-section">
        @for (ctrl of tags.controls; track $index; let i = $index) {
          <mat-form-field appearance="outline">
            <mat-label>Tag {{ i + 1 }}</mat-label>
            <input matInput [formControlName]="i">
            <button matSuffix mat-icon-button type="button" (click)="removeTag(i)">
              <mat-icon>close</mat-icon>
            </button>
          </mat-form-field>
        }
        <button mat-stroked-button type="button" (click)="addTag()"
                [disabled]="tags.length >= 10">+ Tag</button>
      </div>

      <div class="form-actions">
        <button mat-button type="button" routerLink="/admin/posts">Cancel</button>
        <button mat-stroked-button type="button"
                (click)="onSave('draft')" [disabled]="isSaving()">Save Draft</button>
        <button mat-raised-button color="primary" type="button"
                (click)="onSave('published')" [disabled]="form.invalid || isSaving()">
          Publish
        </button>
      </div>
    </form>
  `,
})
export class PostFormComponent implements OnInit, HasUnsavedChanges {
  @Input() postId?: number;   // undefined for create, set for edit

  private fb     = inject(FormBuilder);
  private api    = inject(PostsApiService);
  private router = inject(Router);

  isSaving    = signal(false);
  isEditMode  = computed(() => this.postId !== undefined);
  private destroyRef = inject(DestroyRef);

  form = this.fb.nonNullable.group({
    title:   ['', [Validators.required, Validators.minLength(5), Validators.maxLength(200)]],
    slug:    ['', { validators: [Validators.required, slugFormatValidator()],
                    asyncValidators: [slugUniqueValidator(this.api, this.postId)],
                    updateOn: 'blur' }],
    body:    ['', [Validators.required, Validators.minLength(50)]],
    excerpt: ['', Validators.maxLength(500)],
    tags:    this.fb.array([this.fb.nonNullable.control('')]),
  });

  get tags(): FormArray<FormControl<string>> { return this.form.controls.tags; }

  // ── Unsaved changes detection ─────────────────────────────────────────
  hasUnsavedChanges(): boolean {
    return this.form.dirty;
  }

  ngOnInit() {
    // Auto-generate slug from title in create mode
    if (!this.isEditMode()) {
      this.form.controls.title.valueChanges.pipe(
        debounceTime(300),
        distinctUntilChanged(),
        takeUntilDestroyed(this.destroyRef),
      ).subscribe(title => {
        const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
        this.form.controls.slug.setValue(slug, { emitEvent: false });
      });
    }

    // Load existing post in edit mode
    if (this.isEditMode()) {
      this.api.getById(this.postId!).subscribe(post => {
        this.form.patchValue({ title: post.title, slug: post.slug,
                               body: post.body, excerpt: post.excerpt ?? '' });
        // Populate tags array
        this.tags.clear();
        post.tags.forEach(tag => this.tags.push(this.fb.nonNullable.control(tag)));
        this.form.markAsPristine();  // not dirty after initial load
      });
    }
  }

  addTag():            void { this.tags.push(this.fb.nonNullable.control('')); }
  removeTag(i: number): void { if (this.tags.length > 1) this.tags.removeAt(i); }

  onSave(status: 'draft' | 'published'): void {
    if (status === 'published' && this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    this.isSaving.set(true);
    const data = { ...this.form.getRawValue(),
                   tags: this.tags.value.filter(t => t.trim()), status };

    const request$ = this.isEditMode()
      ? this.api.update(this.postId!, data)
      : this.api.create(data);

    request$.subscribe({
      next:  () => { this.form.markAsPristine(); this.router.navigate(['/admin/posts']); },
      error: (err: HttpErrorResponse) => {
        this.isSaving.set(false);
        if (err.status === 400 && err.error?.errors) {
          Object.entries(err.error.errors as Record<string, string[]>).forEach(([field, msgs]) => {
            this.form.get(field)?.setErrors({ serverError: msgs[0] });
          });
        }
      },
    });
  }
}
Note: form.markAsPristine() after initial data load prevents the unsaved changes guard from triggering when the user navigates away without making any changes. Without this, loading the edit form immediately marks it as potentially changed (because patchValue sets dirty state on some controls). Always call markAsPristine() (and optionally markAsUntouched()) after populating a form with existing data.
Tip: The single PostFormComponent that handles both create and edit modes (distinguished by the optional @Input() postId) reduces code duplication. The form definition, validation, tag management, and submit handling are shared. Only the data loading and the API endpoint differ. This “smart single form” pattern is the standard for CRUD forms in Angular — it avoids maintaining two nearly-identical form components that drift apart over time.
Warning: When using debounceTime(300) for slug auto-generation, combine with distinctUntilChanged() to prevent updates when the value hasn’t actually changed (e.g., typing and then deleting to the same value). Without distinctUntilChanged(), each 300ms debounce fires regardless of whether the value changed since the last emission. The combination ensures the slug only regenerates when the title genuinely changes.

Common Mistakes

Mistake 1 — Not calling markAsPristine() after loading edit data (false “unsaved changes” warning)

❌ Wrong — patchValue sets form to dirty; user sees “unsaved changes” warning even though they haven’t changed anything.

✅ Correct — call form.markAsPristine() after populating the form with existing data.

Mistake 2 — Separate create and edit components with duplicated form code

❌ Wrong — PostCreateComponent and PostEditComponent each define the same form, validators, and submit logic.

✅ Correct — single PostFormComponent with optional @Input() postId that handles both modes.

🧠 Test Yourself

The post form has canDeactivate: [unsavedChangesGuard]. The user edits the title, then navigates away. What determines if the guard shows the warning?