Rich text in Angular reactive forms, step by step

Sooner or later every Angular app grows a form with a rich text field: a ticket description, a product note, a CMS body. And that’s exactly where most editor integrations fall apart - Validators.required that always passes, reset() that leaves the form dirty, disable() that the toolbar happily ignores. In this tutorial we build a support ticket form whose editor behaves like a first-class form control, and we deal with each of those traps properly.

Everything here uses Domternal, an MIT-licensed rich text editor built on ProseMirror, whose Angular components are native standalone components with signals, not a wrapper around a React binding. Every snippet below was run, not just compiled - the videos are recorded from this tutorial’s own component, in a zoneless Angular 21 app.

What you’ll build

Here’s the finished form as a working form control:

Step 1: Install

Terminal window
npm install @domternal/core @domternal/theme @domternal/angular @domternal/extension-block-menu

Four packages: the headless core, the default theme, the Angular components, and the block-menu extension. That last one is a peer dependency of @domternal/angular - the floating menu component imports from it - so it has to be installed even if you never use it directly.

Then add the theme to your global stylesheet:

styles.scss
@use '@domternal/theme';

Step 2: An editor bound to a FormControl

Start with the smallest thing that works:

import { Component, ChangeDetectionStrategy } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { DomternalEditorComponent } from '@domternal/angular';
import { StarterKit } from '@domternal/core';
@Component({
selector: 'app-ticket-form',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, DomternalEditorComponent],
template: `
<domternal-editor [extensions]="extensions" [formControl]="description" />
`,
})
export class TicketFormComponent {
extensions = [StarterKit];
description = new FormControl('<p>The export button does nothing.</p>');
}

Four things worth knowing before we go further:

StarterKit bundles the document schema, headings, lists, task lists, formatting marks, link handling and history; any piece can be switched off with StarterKit.configure().

Step 3: The ticket form

A real form has more than one field, so let’s move to a FormGroup with a subject input and a toolbar above the editor:

import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { DomternalEditorComponent, DomternalToolbarComponent } from '@domternal/angular';
import { StarterKit, Placeholder } from '@domternal/core';
import type { Editor } from '@domternal/core';
@Component({
selector: 'app-ticket-form',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, DomternalEditorComponent, DomternalToolbarComponent],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="subject" placeholder="Subject" />
@if (editor(); as ed) {
<domternal-toolbar [editor]="ed" />
}
<domternal-editor
[extensions]="extensions"
formControlName="description"
(editorCreated)="editor.set($event)"
/>
<button type="submit">Create ticket</button>
</form>
`,
})
export class TicketFormComponent {
extensions = [
StarterKit,
Placeholder.configure({ placeholder: 'Describe the problem…' }),
];
editor = signal<Editor | null>(null);
form = new FormGroup({
subject: new FormControl(''),
description: new FormControl(''),
});
submit(): void {
console.log(this.form.getRawValue());
}
}

What’s new:

Step 4: Required validation that actually works

Here’s the trap this whole tutorial exists for. Add Validators.required to description, delete everything in the editor, and the form stays valid. The reason: a rich text document can never be truly empty - the schema always keeps one empty paragraph - so the control value is '<p></p>'. That’s a seven-character string, and required only fails on null or length zero. The same applies in JSON mode, where the value is a non-null object.

So don’t ask the string; ask the editor:

import type { ValidatorFn } from '@angular/forms';
function richTextRequired(editor: () => Editor | null): ValidatorFn {
return () => {
const ed = editor();
return !ed || ed.isEmpty ? { required: true } : null;
};
}

A signal is just a function that returns the current value, so the component’s editor signal slots straight in. Attach the validator once the editor exists, and add a touched signal we’ll need in a moment:

touched = signal(false);
onEditorCreated(ed: Editor): void {
this.editor.set(ed);
const description = this.form.controls.description;
description.addValidators(richTextRequired(this.editor));
description.updateValueAndValidity();
}

And show the error only after the user has been in the field, the way every other input behaves:

<domternal-editor
[extensions]="extensions"
formControlName="description"
(editorCreated)="onEditorCreated($event)"
(blurChanged)="touched.set(true)"
/>
@if (touched() && form.controls.description.hasError('required')) {
<span class="error">A description is required.</span>
}
<button type="submit" [disabled]="form.invalid">Create ticket</button>

Two details that matter:

Step 5: A character limit with a live counter

Ticket descriptions shouldn’t be novels. CharacterCount enforces a limit inside the editor itself:

import { StarterKit, Placeholder, CharacterCount } from '@domternal/core';
import type { Editor, CharacterCountStorage } from '@domternal/core';
extensions = [
StarterKit,
Placeholder.configure({ placeholder: 'Describe the problem…' }),
CharacterCount.configure({ limit: 500 }),
];
remaining = signal(500);
updateCounter(ed: Editor): void {
const storage = ed.storage['characterCount'] as CharacterCountStorage;
this.remaining.set(storage.remaining());
}

Wire the counter to the (contentUpdated) output, and call updateCounter(ed) once in onEditorCreated so it starts correct:

<domternal-editor
...
(contentUpdated)="updateCounter($event.editor)"
/>
<span class="counter">{{ remaining() }} characters left</span>

How it behaves:

Step 6: Lock, reset, submit

Three buttons finish the form, plus a panel that renders the submitted payload (the json pipe comes from JsonPipe in @angular/common):

<div class="actions">
<button type="submit" [disabled]="form.invalid">Create ticket</button>
<button type="button" (click)="resetForm()">Reset</button>
<button type="button" (click)="toggleLock()">
{{ form.controls.description.disabled ? 'Unlock' : 'Lock' }}
</button>
</div>
@if (submitted(); as payload) {
<pre class="payload">{{ payload | json }}</pre>
}
submitted = signal<unknown>(null);
toggleLock(): void {
const description = this.form.controls.description;
if (description.disabled) {
description.enable();
} else {
description.disable();
}
}
resetForm(): void {
this.form.reset();
this.form.markAsPristine();
this.touched.set(false);
this.submitted.set(null);
}
submit(): void {
if (this.form.invalid) return;
this.submitted.set(this.form.getRawValue());
}

Each one has a sharp edge worth knowing:

HTML or JSON?

By default the form value is an HTML string. If your backend stores structured content, switch the editor to ProseMirror JSON:

<domternal-editor ... outputFormat="json" />

Now the control holds a document object (not a JSON string), so type it accordingly - FormControl<JSONContent | null> with JSONContent from @domternal/core. Initial values and anything you setValue must then be JSON too. Pick one format per control and stay with it; the editor compares and writes values in the configured format, so mixing the two leaves the form and the editor out of sync.

The full component

import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import type { ValidatorFn } from '@angular/forms';
import { JsonPipe } from '@angular/common';
import { DomternalEditorComponent, DomternalToolbarComponent } from '@domternal/angular';
import { StarterKit, Placeholder, CharacterCount } from '@domternal/core';
import type { Editor, CharacterCountStorage } from '@domternal/core';
function richTextRequired(editor: () => Editor | null): ValidatorFn {
return () => {
const ed = editor();
return !ed || ed.isEmpty ? { required: true } : null;
};
}
@Component({
selector: 'app-ticket-form',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, JsonPipe, DomternalEditorComponent, DomternalToolbarComponent],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" class="ticket-form">
<input formControlName="subject" placeholder="Subject" />
@if (editor(); as ed) {
@if (editorCmp.isEditable()) {
<domternal-toolbar [editor]="ed" />
}
}
<domternal-editor
#editorCmp
[extensions]="extensions"
formControlName="description"
(editorCreated)="onEditorCreated($event)"
(contentUpdated)="updateCounter($event.editor)"
(blurChanged)="touched.set(true)"
/>
<div class="meta">
@if (touched() && form.controls.description.hasError('required')) {
<span class="error">A description is required.</span>
}
<span class="counter">{{ remaining() }} characters left</span>
</div>
<div class="actions">
<button type="submit" [disabled]="form.invalid">Create ticket</button>
<button type="button" (click)="resetForm()">Reset</button>
<button type="button" (click)="toggleLock()">
{{ form.controls.description.disabled ? 'Unlock' : 'Lock' }}
</button>
</div>
@if (submitted(); as payload) {
<pre class="payload">{{ payload | json }}</pre>
}
</form>
`,
styles: `
.ticket-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 720px; margin: 0 auto; }
input { padding: 0.6rem 0.8rem; border: 1px solid #d6dae1; border-radius: 8px; font: inherit; }
domternal-editor { min-height: 10rem; }
.meta { display: flex; min-height: 1.25rem; font-size: 0.875rem; }
.error { color: #c43d3d; }
.counter { color: #8a8f98; margin-left: auto; }
.actions { display: flex; gap: 0.5rem; }
button { padding: 0.5rem 1rem; border: 1px solid #d6dae1; border-radius: 8px; background: #fff; font: inherit; cursor: pointer; }
button[type='submit'] { background: #1f6feb; border-color: #1f6feb; color: #fff; }
button[type='submit']:disabled { opacity: 0.45; cursor: not-allowed; }
.payload { background: #f6f8fa; border: 1px solid #e4e8ee; border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.8125rem; overflow: auto; }
`,
})
export class TicketFormComponent {
extensions = [
StarterKit,
Placeholder.configure({ placeholder: 'Describe the problem…' }),
CharacterCount.configure({ limit: 500 }),
];
editor = signal<Editor | null>(null);
remaining = signal(500);
touched = signal(false);
submitted = signal<unknown>(null);
form = new FormGroup({
subject: new FormControl(''),
description: new FormControl(''),
});
onEditorCreated(ed: Editor): void {
this.editor.set(ed);
const description = this.form.controls.description;
description.addValidators(richTextRequired(this.editor));
description.updateValueAndValidity();
this.updateCounter(ed);
}
updateCounter(ed: Editor): void {
const storage = ed.storage['characterCount'] as CharacterCountStorage;
this.remaining.set(storage.remaining());
}
submit(): void {
if (this.form.invalid) return;
this.submitted.set(this.form.getRawValue());
}
resetForm(): void {
this.form.reset();
this.form.markAsPristine();
this.touched.set(false);
this.submitted.set(null);
}
toggleLock(): void {
const description = this.form.controls.description;
if (description.disabled) {
description.enable();
} else {
description.disable();
}
}
}

That’s a rich text field that validates, counts, locks, resets and submits like every other control in your form - no zone.js required, no markForCheck() anywhere.

Where to go next