Text Editor - Controlled and Uncontrolled

How v-model differs from a one-way :model-value, what an uncontrolled editor is for, and how the editor plugs into forms.

#Overview

modelValue on <TextEditor.Root> is optional. Binding it — and binding it two-way — decides who owns the document.

ShapeWho owns the valueUse it for
v-modelThe consumerAnything that reads or reacts to the content while the user types
:model-value onlyThe editor, seeded onceRead-mostly content, or a preview never read back
neitherThe editorA scratch surface whose content is only pulled on demand

The editor never resets itself. Whatever the tree renders, the document survives its re-renders — the ProseMirror instance is created once and kept across parent updates, so undo history and the caret are never lost by a re-render.

#Controlled

Bind v-model. This is the default shape and the one every demo uses.

<script setup lang="ts">
import { TextEditor } from '@primeui/vue-texteditor';
import { ref } from 'vue';

const value = ref('<p>Hello</p>');
</script>

<template>
    <TextEditor.Root v-model="value">
        <TextEditor.Content />
    </TextEditor.Root>
</template>

update:modelValue fires on every edit with the serialized document — an HTML string in classic mode, a string[] of block HTML in block mode. Writing a new value back in pushes it into the document; writing back the value the editor just emitted is ignored, so the round trip does not loop.

For a large document, debounce the emit rather than the state update:

<TextEditor.Root v-model="value" :value-change-debounce="300">

#Uncontrolled

Leave modelValue off entirely, or bind it one-way with :model-value. The editor keeps the document internally.

<TextEditor.Root>
    <TextEditor.Content />
</TextEditor.Root>
<TextEditor.Root :model-value="'<p>Seeded once</p>'">
    <TextEditor.Content />
</TextEditor.Root>

A one-way binding seeds the document once. Editing continues from there, the ref stays put, and nothing above the editor re-renders on a keystroke. That is the point of this shape: a component that never reads the content should not pay to watch it.

A different value written into the ref still lands in the document later. Re-writing the ref with what it already holds does not: it is not a change, so nothing syncs and the edits stay. Build discard buttons on v-model instead, where ref and document never drift apart.

Read the content on demand, through the context. useTextEditorContext() injects from the nearest Root, so the control has to live inside it — a sibling of Root has nothing to inject from:

<!-- SaveButton.vue -->
<script setup lang="ts">
import { useTextEditorContext } from '@primeui/vue-texteditor';

const { getHTML } = useTextEditorContext();
</script>

<template>
    <button type="button" @click="save(getHTML())">Save</button>
</template>
<TextEditor.Root>
    <TextEditor.Content />
    <SaveButton />
</TextEditor.Root>

Root also exposes the same serializers on its default slot, which avoids adding a component:

<TextEditor.Root v-slot="{ getHTML }">
    <TextEditor.Content />
    <button type="button" @click="save(getHTML())">Save</button>
</TextEditor.Root>

getHTML, getBlocks, getJSON, getText, and getMarkdown all serialize on demand, and are safe to call before the editor has mounted — they return an empty document rather than throwing.

#Switching between the two

Do not flip a component between controlled and uncontrolled — going from a defined value to undefined is the one shape the editor cannot interpret, because "no value" and "empty document" are not the same thing. Pick one per instance.

Passing a value late is fine, and is the normal shape when a record loads after mount:

const value = ref<string | undefined>(undefined);

onMounted(async () => {
    value.value = await fetchDoc(); // arrives after mount — applied to the document
});

#Native forms

Set name and Root renders a hidden <input> carrying the serialized value, so a plain <form> submit includes the content with no extra wiring. required is forwarded to that input.

<form action="/posts" method="post">
    <TextEditor.Root name="body" required>
        <TextEditor.Content />
    </TextEditor.Root>
    <button type="submit">Publish</button>
</form>

This works in both shapes: the hidden input tracks the live document whether or not modelValue is also bound.

#Form libraries

The editor exposes the modelValue / update:modelValue pair that Vue form libraries expect, so a controlled field maps directly.

VeeValidate — wire it through useField:

<script setup lang="ts">
import { useField } from 'vee-validate';

const { value: body } = useField<string>('body');
</script>

<template>
    <TextEditor.Root v-model="body">
        <TextEditor.Content />
    </TextEditor.Root>
</template>

FormKit or any store-backed form — read from state and write with a custom setter:

<TextEditor.Root :model-value="form.body" @update:model-value="(v) => (form.body = v)">
    <TextEditor.Content />
</TextEditor.Root>

Two behaviours these libraries depend on, both guaranteed:

  • A handler whose identity changes every render is fine. The editor reads the latest one rather than capturing it, so it never fires a stale handler and never rebuilds itself.
  • A reset applies. Writing the pristine value back into the bound ref replaces the document, so a form reset restores the original content.

For validation, required covers the native case. Anything richer — a minimum length, a "not just an empty paragraph" rule — belongs in the library's own schema, reading the same string the editor emits.

#Disabled and readonly

Both are independent of the controlled/uncontrolled choice.

<TextEditor.Root v-model="value" disabled>
<TextEditor.Root v-model="value" readonly>

disabled blocks editing and greys the UI; readonly blocks editing but keeps the content selectable and copyable. Both reject document-mutating transactions at the source, so a toolbar command cannot bypass them.