← Blog
25 Oct 2026Vue 3editable textareainline editingfocus managementdraft state

Editable-Textarea in Vue: Build an Inline Editor That Preserves Drafts

Build an editable-textarea in Vue 3 with a separate draft, predictable focus, save and cancel actions, and protection against external value changes.

Editable-Textarea in Vue: Build an Inline Editor That Preserves Drafts

TL;DR

To build an editable-textarea in Vue, show the saved text until someone chooses Edit, then copy it into a local textarea draft. Focus the field after Vue renders it. Save explicitly, cancel without changing the parent value, and block a stale save if the parent value changes during editing. Keep the textarea labeled and return focus to Edit when the editor closes.

A regular textarea is always an input. An in-place editable textarea is an interaction: a note appears as text, becomes a multiline field on demand, then returns to text after Save or Cancel. That extra state transition is where drafts disappear if we bind every keystroke to a parent value that can refresh independently.

We will build one Vue 3 component for a team note. It uses a native textarea for plain text, not an editable div or a rich-text editor. If you only need an always-visible multiline form field, use DOM Studio’s Textarea Input reference instead; its documented v-model, label, and rows cover that simpler case. This guide focuses on the display-to-edit behavior. Vue’s form-binding guide documents the native textarea binding used below.

Table of contents

Before you start

Have a Vue 3 project that accepts single-file components, a view where you can render a note, and a browser for keyboard testing. Create InlineNoteEditor.vue next to a parent App.vue. The example requires no UI package and stores its value only in the parent component’s memory. It does not send data to a server or survive a page reload.

Our product rule is deliberate: Enter inserts a newline, Save commits, Cancel discards, Escape cancels when the textarea is not composing text, and clicking elsewhere does neither. An empty note is allowed. Choose a different empty-value rule before adding validation, rather than trimming someone’s text silently. For a quick refresher on Vue form inputs before implementing the editor, this independent video covers the fundamentals of forms and input binding; it is not a demonstration of the save/cancel interaction built here.

1. Separate the displayed value from the editable draft

Start with two ownership rules. The parent owns the saved note, exposed to the child as modelValue. The child owns a temporary draft that exists while editing. Opening the editor copies modelValue into draft; typing changes only draft. Save emits update:modelValue, which the parent handles through v-model. Cancel emits nothing. Vue documents both native textarea v-model and the component modelValue/update:modelValue convention. See Vue’s form bindings and component v-model guide.

This is not a live-updating input masquerading as an editor. If the parent re-fetches a note while someone is midway through a sentence, a local draft should not be replaced by the fetched string. Conversely, ignoring every parent update and saving blindly could overwrite somebody else’s change. We will compare the current parent value with the value we copied when editing began. A mismatch blocks Save until the user explicitly reloads the latest value or cancels.

Flow from parent saved value to editable draft, with save, cancel, and external-update conflict branches

Check: the saved value and the draft have different roles. If your parent must see every keystroke, you are designing live editing, not the explicit commit-on-save flow in this guide. Troubleshooting: do not bind the textarea directly to a parent v-model and then try to implement Cancel by guessing an old value; that makes undo and outside updates ambiguous.

2. Switch modes and move focus after rendering

Use an ordinary Edit button beside the read-only text. It is tabbable and has a predictable accessible name. When activated, switch to edit mode and wait for nextTick() before focusing the newly rendered textarea. Vue batches DOM updates, and a template ref to a conditionally rendered element is unavailable until that element exists. When Save or Cancel removes the editor, wait again and focus the returning Edit button. Vue explains template refs and nextTick; the W3C guidance emphasizes keeping focus visible and predictable when focused elements disappear. Read the keyboard-interface guidance.

In-place note editor changing from a keyboard-accessible preview to a focused multiline field

We do not save on blur. A user may move focus to reread surrounding content, open a tooltip, or use assistive technology before choosing an action. An automatic blur save would make those actions change data. Keep visible Save and Cancel buttons; let Tab reach both. We also keep Escape as a convenience, not as the only exit.

Expected result: keyboard activation of Edit opens a labeled field and places the caret there; Save or Cancel closes it and leaves focus on Edit. Troubleshooting: if focus lands on the page body, check that the button has been rendered before .focus() runs and that no parent removes the entire editor at the same moment.

3. Implement the editor and a parent-owned value

Save this as InlineNoteEditor.vue. The modelValue and label props are required. The hasConflict comparison is intentionally small: it protects an unsaved local draft from being silently replaced by a changed parent value. Because the visible note uses Vue interpolation rather than HTML injection, the displayed plain text remains text; white-space: pre-wrap preserves its newlines and wraps long lines. MDN documents pre-wrap.

<script setup>
import { computed, nextTick, ref } from 'vue'

const props = defineProps({
  modelValue: { type: String, required: true },
  label: { type: String, required: true },
})
const emit = defineEmits(['update:modelValue'])

const editing = ref(false)
const draft = ref('')
const startingValue = ref('')
const textarea = ref(null)
const editButton = ref(null)

const hasConflict = computed(
  () => editing.value && props.modelValue !== startingValue.value
)

async function beginEdit() {
  startingValue.value = props.modelValue
  draft.value = props.modelValue
  editing.value = true
  await nextTick()
  textarea.value?.focus()
}

async function finishEdit() {
  editing.value = false
  await nextTick()
  editButton.value?.focus()
}

async function save() {
  if (hasConflict.value) return
  emit('update:modelValue', draft.value)
  await finishEdit()
}

function cancel() {
  draft.value = ''
  finishEdit()
}

async function loadLatest() {
  draft.value = props.modelValue
  startingValue.value = props.modelValue
  await nextTick()
  textarea.value?.focus()
}

function onEscape(event) {
  if (event.isComposing) return
  event.preventDefault()
  event.stopPropagation()
  cancel()
}
</script>

<template>
  <section class="note-editor">
    <template v-if="!editing">
      <p class="note-label">{{ props.label }}</p>
      <p class="note-preview">{{ props.modelValue || 'No note yet.' }}</p>
      <button ref="editButton" type="button" @click="beginEdit">
        Edit {{ props.label }}
      </button>
    </template>

    <template v-else>
      <label class="note-field">
        <span>{{ props.label }}</span>
        <textarea
          ref="textarea"
          v-model="draft"
          rows="4"
          @keydown.esc="onEscape"
        ></textarea>
      </label>
      <p>Enter adds a line. Choose Save or Cancel; Escape cancels.</p>
      <p v-if="hasConflict" role="alert">
        This note changed elsewhere. Your draft is still here.
        Reload the latest value to discard your draft, or cancel.
      </p>
      <div class="note-actions">
        <button type="button" :disabled="hasConflict" @click="save">
          Save
        </button>
        <button v-if="hasConflict" type="button" @click="loadLatest">
          Load latest and discard draft
        </button>
        <button type="button" @click="cancel">Cancel</button>
      </div>
    </template>
  </section>
</template>

<style scoped>
.note-editor { max-width: 38rem; }
.note-label { font-weight: 700; }
.note-preview { white-space: pre-wrap; overflow-wrap: anywhere; }
.note-field { display: grid; gap: 0.5rem; }
textarea { width: 100%; box-sizing: border-box; font: inherit; }
.note-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
button:focus-visible, textarea:focus-visible {
  outline: 3px solid #175cd3;
  outline-offset: 2px;
}
</style>

We wrap the textarea in a visible <label> rather than relying on placeholder text. The W3C explains that an associated label supplies a control’s name to assistive technology; for repeated complex forms, consider explicit unique IDs and error descriptions as well. See W3C’s labeling tutorial. We let the browser keep native multiline editing behavior. Vue’s v-model uses the textarea’s value and input event, so a newline remains part of draft until we explicitly save it. Vue details textarea binding.

Now render it from App.vue. The external-update button is just a local test fixture, not a network request:

<script setup>
import { ref } from 'vue'
import InlineNoteEditor from './InlineNoteEditor.vue'

const note = ref('Morning handoff:\nConfirm who owns the release.')

function simulateExternalUpdate() {
  note.value = 'Updated from another view.\nReview before saving.'
}
</script>

<template>
  <main>
    <h1>Team notes</h1>
    <InlineNoteEditor v-model="note" label="Team note" />
    <button type="button" @click="simulateExternalUpdate">
      Simulate external update
    </button>
  </main>
</template>

Expected result: editing changes only the textarea until Save. Cancel restores the displayed value, and pressing the simulation button while editing shows a conflict without wiping the draft. Troubleshooting: if your project stores the parent note in a different variable, bind that variable with v-model; do not mutate the read-only modelValue prop in the child.

4. Exercise the conflict and keyboard paths

Try the complete flow instead of judging the code by its initial screen:

  1. Tab to Edit Team note and press Enter or Space. Type a second line; confirm the displayed note has not yet changed. Tab to Save and activate it. Reopen the editor and confirm both lines are present.
  2. Type a different paragraph and choose Cancel, or press Escape while focused in the textarea. Reopen and confirm the draft was discarded. If an input method editor is composing text, the Escape handler does not cancel on that composing key event.
  3. Enter edit mode, type a draft, then activate Simulate external update. Return to the editor. The conflict warning should appear, Save should be disabled, and your unsaved text should remain in the textarea.
  4. Select Load latest and discard draft only when willing to lose that unsaved text. The field should receive focus again and contain the new parent value. Save it or cancel it. Repeat the conflict scenario and select Cancel to keep the external value.
  5. Clear every character and Save. The preview should say “No note yet.” Reopen: the actual editable value should be empty, not that placeholder phrase. Paste text with blank lines and confirm the line breaks in the preview.

If the last check fails, look for .trim or HTML rendering added elsewhere in your app. This example intentionally saves the draft verbatim and uses a visible empty-state message only when displaying it. It has no submission form and no required constraint; add validation only when your product actually forbids blank notes.

5. Connect real persistence without losing a draft

In the demo, emitting update:modelValue synchronously changes parent memory. A real API adds failure, loading, and concurrent edits. Do not close the editor and claim “Saved” just because a request was sent. Instead, let the parent own the request, disable duplicate saves while it is pending, show a recoverable error, and keep the draft available when the request fails. If the server tracks a version or ETag, submit it with the proposed value and reject stale writes on the server. Comparing strings locally only notices a changed prop; it cannot protect against another client updating the server between your comparison and the request, or an intermediate change that later returns to the same text.

For example, if the API rejects a stale version, keep editing and present a deliberate resolution choice. A merge UI may be appropriate for collaborative notes; this small example chooses the safer, simpler reload or cancel policy. Do not auto-replace text someone has already typed. If the editor is unmounted during a route change, local draft state will also disappear, so move drafts to longer-lived application state if navigation must preserve them. Those are application-specific persistence and concurrency requirements, not behaviors supplied by the snippet above.

Check: simulate a failed save, a slow save, and a stale-version response in your own integration before shipping. The expected result is no false success, no duplicate write, and no lost draft. Troubleshooting: if server responses arrive out of order, match each response to its note identity and version before applying it to parent state.

FAQ

Is this the same as a read-only textarea?

No. A read-only textarea remains a form control whose value cannot be edited; this pattern displays ordinary text and creates a textarea only when editing begins. The difference matters for focus order, accessible naming, and where a draft lives. MDN explains native textarea attributes.

Why not save when the field loses focus?

Blur can mean the user is reading another part of the page or moving to a control, not that the draft is finished. Here, only Save commits and only Cancel or Escape discards. If you choose blur-to-save, document it and test interactions with menus, errors, and touch keyboards.

Why doesn’t the display preserve my line breaks?

Default text layout can collapse newlines. Keep the value as plain text and use white-space: pre-wrap on its display element; do not insert user text as raw HTML. See MDN’s white-space reference.

Put the interaction into your app

We now have an inline note that keeps its draft separate, handles focus, and refuses to overwrite a changed parent value without a conscious choice. Next, connect your actual save endpoint and verify failure and version-conflict behavior with real responses. If a normal always-visible textarea is all your screen needs, explore DOM Studio’s Textarea Input rather than adding an unnecessary edit mode.

Sources

Recommended Reads