react-formbridge
Browse documentation
Core conceptsv1.0.2

Draft persistence

Draft persistence lets a form survive refreshes, tab changes, route changes, or interrupted sessions.

  • Great for checkout flows, onboarding, long settings screens, or mobile forms that may be backgrounded
  • The runtime restores saved values on mount and exposes helpers to manage that lifecycle
  • Built-in targets cover local, session, and async, and you can plug in a custom StorageAdapter when drafts must go through encrypted, remote, or app-specific storage
import { useCallback, useEffect, useState } from 'react'
import { field, useFormBridge } from '@runilib/react-formbridge'

const STORAGE_PREFIX = 'react-formbridge:'
const PERSIST_KEY = 'docs:persist:local-profile'
const STORAGE_KEY = STORAGE_PREFIX + PERSIST_KEY
const EMPTY_VALUES = {
  fullName: '',
  email: '',
  notes: '',
  newsletter: true,
  otp: '',
}

const schema = {
  fullName: field.text('Full name').required(),
  email: field.email('Email').required(),
  notes: field.textarea('Notes').required().min(10, 'Write at least 10 characters.'),
  newsletter: field.checkbox('Email me product updates'),
  otp: field.text('One-time code').placeholder('Excluded from persistence'),
}

function LocalDraftForm({
  onRefreshStorage,
  onRemount,
  onRestore,
  onSubmitted,
}: {
  onRefreshStorage: () => void
  onRemount: () => void
  onRestore: () => void
  onSubmitted: (values: Record<string, unknown>) => void
}) {
  const form = useFormBridge(schema, {
    validateOn: 'onBlur',
    revalidateOn: 'onChange',
    initialValues: {
      newsletter: true,
    },
    persist: {
      key: PERSIST_KEY,
      storage: 'local',
      ttl: 5 * 60,
      debounce: 300,
      exclude: ['otp'],
      version: 'docs-local-v1',
      onRestore: () => {
        onRestore()
        onRefreshStorage()
      },
    },
  })

  const { Form, fields, persistanceHelpers, state } = form

  useEffect(() => {
    if (typeof window === 'undefined') return undefined

    const timer = window.setTimeout(() => {
      onRefreshStorage()
    }, 350)

    return () => window.clearTimeout(timer)
  }, [onRefreshStorage, state.values])

  return (
    <>
      <Form
        onSubmit={async (values) => {
          onSubmitted(values)
        }}
      >
        <div style={{ display: 'grid', gap: 12 }}>
          <fields.fullName />
          <fields.email />
          <fields.notes />
          <fields.newsletter />
          <fields.otp />

          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            <button
              type="button"
              onClick={async () => {
                await persistanceHelpers.saveDraftNow()
                onRefreshStorage()
              }}
            >
              Save draft now
            </button>

            <button
              type="button"
              onClick={async () => {
                await persistanceHelpers.saveDraftNow()
                onRefreshStorage()
                onRemount()
              }}
            >
              Save + remount
            </button>

            <button
              type="button"
              onClick={async () => {
                await persistanceHelpers.clearDraft()
                onRefreshStorage()
              }}
            >
              Clear saved draft
            </button>

            <button
              type="button"
              onClick={() => {
                form.resetFields(EMPTY_VALUES)
              }}
            >
              Reset fields only
            </button>
          </div>

          <Form.Submit>Submit values</Form.Submit>
        </div>
      </Form>

      <div
        style={{
          border: '1px solid #d6d9e0',
          borderRadius: 12,
          padding: 12,
          background: '#fff',
          marginTop: 16,
        }}
      >
        <strong>Runtime snapshot</strong>
        <p style={{ margin: '8px 0', color: '#4b5563', fontSize: 13 }}>
          Loading draft: {String(persistanceHelpers.isLoadingDraft)}
          {' · '}
          Restored on mount: {String(persistanceHelpers.hasDraft)}
        </p>
        <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
          {JSON.stringify(state.values, null, 2)}
        </pre>
      </div>
    </>
  )
}

export function PersistenceLocalStoragePlayground() {
  const [instanceId, setInstanceId] = useState(0)
  const [restoreCount, setRestoreCount] = useState(0)
  const [rawDraft, setRawDraft] = useState<string | null>(null)
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null)

  const refreshStorage = useCallback(() => {
    if (typeof window === 'undefined') return
    setRawDraft(window.localStorage.getItem(STORAGE_KEY))
  }, [])

  useEffect(() => {
    refreshStorage()
  }, [instanceId, refreshStorage])

  return (
    <div
      style={{
        fontFamily: 'sans-serif',
        padding: 20,
        background: '#f5f7fb',
        display: 'grid',
        gap: 16,
      }}
    >
      <div>
        <h3 style={{ margin: '0 0 8px' }}>Interactive localStorage persistence</h3>
        <p style={{ margin: 0, color: '#4b5563' }}>
          Type into the form, click <strong>Save + remount</strong>, and the same
          draft is restored from browser local storage. The OTP field is excluded
          on purpose.
        </p>
      </div>

      <LocalDraftForm
        key={instanceId}
        onRefreshStorage={refreshStorage}
        onRemount={() => setInstanceId((current) => current + 1)}
        onRestore={() => setRestoreCount((current) => current + 1)}
        onSubmitted={setSubmitted}
      />

      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
          gap: 12,
        }}
      >
        <div
          style={{
            border: '1px solid #d6d9e0',
            borderRadius: 12,
            padding: 12,
            background: '#fff',
          }}
        >
          <strong>Persist configuration</strong>
          <p style={{ margin: '8px 0', color: '#4b5563', fontSize: 13 }}>
            Key: <code>{STORAGE_KEY}</code>
          </p>
          <p style={{ margin: '8px 0', color: '#4b5563', fontSize: 13 }}>
            TTL: 5 minutes
            {' · '}
            Debounce: 300ms
            {' · '}
            Version: docs-local-v1
          </p>
          <p style={{ margin: 0, color: '#4b5563', fontSize: 13 }}>
            Restores observed in this session: <strong>{restoreCount}</strong>
          </p>
        </div>

        <div
          style={{
            border: '1px solid #d6d9e0',
            borderRadius: 12,
            padding: 12,
            background: '#fff',
          }}
        >
          <strong>Last submit</strong>
          <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
            {JSON.stringify(submitted, null, 2)}
          </pre>
        </div>
      </div>

      <div
        style={{
          border: '1px solid #d6d9e0',
          borderRadius: 12,
          padding: 12,
          background: '#fff',
        }}
      >
        <strong>Raw localStorage draft</strong>
        <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
          {rawDraft ?? 'No saved draft yet.'}
        </pre>
      </div>
    </div>
  )
}

export default PersistenceLocalStoragePlayground

When to enable it

Use persistence for long or interruption-prone flows. Exclude secrets such as passwords, PINs, OTPs, CVV, or any field you would not want stored locally.

The interactive demos above show two common setups:

  • a built-in local adapter for browser drafts
  • a custom StorageAdapter when the persistence layer is app-specific

Complete PersistOptions surface:

MethodTypeDescription
keystringRequired storage key namespace
storage?'local' | 'session' | 'async' | customStorage backend. Default 'local'
ttl?numberSeconds before the draft expires. Default 3600
exclude?string[]Field names never written to storage. Default []
debounce?numberMs before writes flush to storage. Default 800
onRestore?(values) => voidCalled after a valid draft is restored
onSaveError?(error) => voidCalled when a draft write fails
version?stringBump to invalidate previously saved drafts. Default '1'

Draft helpers exposed through useFormBridge():

HelperDescription
isLoadingDrafttrue while the runtime is rehydrating a saved draft on mount
hasDrafttrue once a draft was found and applied for this form key
saveDraftNow()Force-flush the current values to storage (bypasses debounce)
clearDraft()Delete the stored draft for this form key