Skip to content

useFormDraft ​

Auto-saves a form in progress to localStorage on an interval, and hands it back when the reader returns.

Open in demo

Import ​

ts
import { useFormDraft } from '@core/schema/useFormDraft'

Signature ​

ts
function useFormDraft<T>(
  key: string,
  getData: () => T,
  options?: UseFormDraftOptions,
): UseFormDraftReturn<T>

interface UseFormDraftOptions {
  intervalMs?: number
  enabled?: MaybeRefOrGetter<boolean>
}

Parameters ​

ParameterTypeDefaultDescription
keystringrequiredStored under sa-draft:{key}.
getData() => TrequiredCalled on every save. A getter, not a ref, because vee-validate's values is a proxy.
options.intervalMsnumber30000How often the interval saves.
options.enabledMaybeRefOrGetter<boolean>trueReactive. While false the interval keeps running but writes nothing.

Returns ​

MemberTypeDescription
restore() => T | nullReads the draft synchronously. null when missing or corrupt.
save() => voidSaves now, in addition to the interval.
clearDraft() => voidRemoves the draft. Call it after a successful submit.

Example ​

vue
<script setup lang="ts">
import { useFormDraft } from '@core/schema/useFormDraft'
import { reactive } from 'vue'

interface Article {
  title: string
  body: string
}

const form = reactive<Article>({ title: '', body: '' })

const { restore, save, clearDraft } = useFormDraft<Article>(
  'article-new',
  () => ({ ...form }),
  { intervalMs: 10_000 },
)

const draft = restore()
if (draft)
  Object.assign(form, draft)

async function submit() {
  await fetch('/api/articles', { method: 'POST', body: JSON.stringify(form) })
  clearDraft()
}
</script>

<template>
  <form class="space-y-3" @submit.prevent="submit()">
    <input v-model="form.title" type="text" placeholder="Title">
    <textarea v-model="form.body" rows="5" />
    <div class="flex gap-2">
      <button type="submit">
        Publish
      </button>
      <button type="button" @click="save()">
        Save draft now
      </button>
    </div>
  </form>
</template>

Notes ​

  • Restore is manual, and that is deliberate. SchemaForm restores before it calls useForm(), so the draft seeds initialValues at first paint rather than overwriting fields a tick later. An onMounted write would flash the empty form first.
  • getData is a getter rather than a ref for the same reason: vee-validate exposes values as a proxy, not a ref, so there is nothing to watch.
  • Nothing here throws. A failed read, write or clear is caught and logged through console.warn, so a full or blocked localStorage cannot break a form.
  • The interval is paused on scope dispose, so a form that unmounts stops saving.
  • A corrupt entry reads as null, which is indistinguishable from "no draft" to the caller. That is the safe default.
  • Keys are namespaced sa-draft: so clearing drafts never touches the rest of the app's storage.
  • useFormDirty — the other half: warning before a reader leaves.
  • Forms — where SchemaForm calls this.