Exact problem: Vue 3’s reactive proxy system silently breaks reference equality, making naive pristine checks return false even for identical data — this page shows a composable that survives proxy wrapping, async hydration, and per-field granularity.

Context and Prerequisites

This page builds on dirty and pristine state tracking — read that first if you need the conceptual model. The composable below is Vue 3-specific; for a React equivalent see how to track dirty fields in React forms.

The core challenge is that Vue wraps every reactive() object in a Proxy. A comparison like baseline === current will always return false even when both contain identical data, because you’re comparing two different proxy objects, not their underlying values. The solution is to keep the baseline as a plain-value clone and compare with deep equality.

Pristine state lifecycle in Vue 3 Shows the flow from initial value through structuredClone into baseline ref and current ref, then computed isPristine derived from deep equality. updateBaseline replaces both refs atomically on async hydration or successful submit. initialValue (plain object) structuredClone structuredClone baseline shallowRef (plain) current ref (v-model target) isPristine computed (isEqual) updateBaseline() replaces both atomically solid = data flow | dashed = atomic reset path

Core Composable

The composable below is the single implementation to ship. Every line that touches Vue’s reactivity or cloning has an inline comment explaining the non-obvious behaviour.

import { ref, computed, type Ref, type ComputedRef } from 'vue';
import isEqual from 'lodash-es/isEqual';

// lodash-es/isEqual handles Date, RegExp, nested arrays, and NaN correctly.
// JSON.stringify is faster but silently drops undefined values and fails on Date.

export interface FieldPristineMap {
  [fieldName: string]: ComputedRef<boolean>;
}

export interface PristineStateReturn<T extends Record<string, unknown>> {
  current: Ref<T>;
  isPristine: ComputedRef<boolean>;
  fieldIsPristine: FieldPristineMap;
  reset: () => void;
  updateBaseline: (newData: T) => void;
}

export function usePristineState<T extends Record<string, unknown>>(
  initialValue: T
): PristineStateReturn<T> {
  // structuredClone produces a deep plain-object copy, breaking any reactive proxy
  // references that Vue may have already attached to initialValue's tree.
  const baseline = ref<T>(structuredClone(initialValue)) as Ref<T>;

  // current is the v-model target — Vue will proxy it, but baseline stays plain.
  const current = ref<T>(structuredClone(initialValue)) as Ref<T>;

  // computed caches the result; re-evaluates only when baseline or current changes.
  // Do NOT use a deep watcher here — it fires on every mutation tick, not just changes.
  const isPristine = computed<boolean>(() =>
    isEqual(baseline.value, current.value)
  );

  // Per-field computed flags: only re-evaluate when that field's value changes.
  // Accessing baseline.value[key] and current.value[key] inside computed() is enough
  // to register the dependency — Vue tracks property accesses during evaluation.
  const fieldIsPristine = Object.fromEntries(
    Object.keys(initialValue).map((key) => [
      key,
      computed<boolean>(() =>
        isEqual(
          (baseline.value as Record<string, unknown>)[key],
          (current.value as Record<string, unknown>)[key]
        )
      ),
    ])
  ) as FieldPristineMap;

  const reset = (): void => {
    // Cast required: structuredClone returns a deep copy typed as T.
    current.value = structuredClone(baseline.value) as T;
  };

  // Call this after async hydration or after a successful submission response.
  // Updating both refs in the same synchronous block avoids a transient dirty flash.
  const updateBaseline = (newData: T): void => {
    baseline.value = structuredClone(newData);
    current.value = structuredClone(newData);
  };

  return { current, isPristine, fieldIsPristine, reset, updateBaseline };
}

Step-by-Step Walkthrough

Step 1 — Clone before storing. structuredClone(initialValue) creates a deep plain copy with no proxy wrapping. Both baseline and current start as structurally identical plain values. If you skip this and store initialValue directly into baseline, any mutation of current will also mutate baseline when nested objects share references.

Step 2 — Keep baseline as a ref, not reactive. Wrapping in reactive() would make Vue proxy baseline, causing isEqual(baseline.value, current.value) to compare two proxies. ref() stores the plain clone under .value without proxying the value’s own internals (only the .value accessor is reactive).

Step 3 — Derive isPristine as a computed. The computed reads baseline.value and current.value, so Vue registers both as reactive dependencies. When either changes, the computed invalidates and isEqual re-runs on the next read. No watchers, no manual bookkeeping.

Step 4 — Per-field flags via Object.fromEntries. The loop creates one computed per key in initialValue. Each one closes over key and compares only that field, so updating email does not trigger re-evaluation of the displayName flag.

Step 5 — Wire into the component with v-model. Bind v-model to current.value field properties. Use updateBaseline in the onMounted lifecycle hook once async data resolves, and call it again after a successful API response to make the saved state the new baseline.

Component Integration

<script setup lang="ts">
import { onMounted } from 'vue';
import { usePristineState } from '@/composables/usePristineState';

interface UserForm {
  email: string;
  displayName: string;
  bio: string;
}

const { current, isPristine, fieldIsPristine, reset, updateBaseline } =
  usePristineState<UserForm>({ email: '', displayName: '', bio: '' });

onMounted(async () => {
  // Server data becomes the baseline — form starts pristine after hydration.
  const data = await fetchUserProfile();
  updateBaseline(data);
});

async function handleSubmit() {
  await saveUserProfile(current.value);
  // After a successful save, the saved state is the new pristine baseline.
  updateBaseline(current.value);
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <label for="email">Email</label>
    <input
      id="email"
      v-model="current.email"
      type="email"
      :aria-invalid="!fieldIsPristine.email && !isValidEmail(current.email)"
      aria-describedby="email-error"
    />
    <!-- aria-invalid reflects validation state, not pristine state -->
    <span id="email-error" role="alert" v-if="!fieldIsPristine.email && !isValidEmail(current.email)">
      Enter a valid email address.
    </span>

    <label for="displayName">Display Name</label>
    <input
      id="displayName"
      v-model="current.displayName"
      type="text"
    />

    <label for="bio">Bio</label>
    <textarea id="bio" v-model="current.bio" />

    <button type="submit" :disabled="isPristine">Save Changes</button>
    <button type="button" @click="reset">Discard Changes</button>
  </form>
</template>

aria-invalid is wired to validation state, not isPristine. A pristine field has not been touched yet — marking it aria-invalid before the user types anything would violate WCAG 2.1 success criterion 3.3.1 (Error Identification), which requires errors to be identified only after input is received.

Failure Modes and Edge Cases

Autofill bypass. Browser autofill can populate inputs without triggering Vue’s input or change events, meaning current stays at its initial empty values while the DOM shows populated fields. Use a MutationObserver on the form element, or listen to the animationstart CSS trick (autofill triggers a pseudo-class animation), to detect autofill and sync current manually.

Date objects losing type fidelity. JSON.stringify/parse converts Date to a string, so isEqual(new Date('2024-01-01'), '2024-01-01') returns false. structuredClone preserves Date as Date and lodash-es/isEqual compares by getTime(). If your form data contains Date fields, stick with this stack — do not mix in JSON.stringify comparisons.

Stale baseline after optimistic updates. If you apply an optimistic UI update to current before the server confirms, and then the server rejects the request, calling reset() restores the pre-submission state — which is correct. Do not call updateBaseline until the server response confirms success; otherwise a failed save permanently shifts the baseline.

watchEffect triggering during SSR. On Nuxt 3, watchEffect runs on the server. A watchEffect that reads current and performs pristine logic will execute in SSR context where the DOM does not exist. Use computed (which is SSR-safe and lazy) rather than watchEffect for pristine derivation. If you need side effects on pristine state change, use watch with { flush: 'post' } and guard with if (import.meta.client). See handling Svelte form hydration mismatches for a parallel problem in another framework.

Proxy comparison in third-party equality libraries. Some older deep-equal implementations inspect the object’s constructor property. Vue’s Proxy objects report their target’s constructor, so this usually works — but if you switch to a library that uses Object.is internally for object identity, all comparisons will return false. Always test your equality function against reactive({}) vs {} before shipping.

Verification Checklist

  • isPristine is true immediately after updateBaseline(serverData) returns
  • isPristine becomes false after typing in any bound input
  • reset() restores isPristine to true
  • updateBaseline(current.value) after a successful save keeps isPristine as true
  • Each fieldIsPristine[key] reflects only that field’s change status
  • aria-invalid is bound to validation error state, not to !isPristine
  • The Save button is disabled when isPristine is true
  • Autofill on email and password inputs updates current and clears isPristine
  • No deep watchers remain — confirm in Vue DevTools by filtering timeline for unexpected ref mutations
  • SSR build (if applicable) does not throw during usePristineState initialisation

FAQ

How does Vue 3 reactivity affect pristine state tracking?

Vue wraps objects in Proxy automatically. Comparing a reactive proxy to a plain object with === always returns false, even when both contain identical data. Always use structuredClone for snapshots and isEqual (or equivalent) for comparison — you are working on the underlying values, not the proxy wrappers.

Should pristine state be tracked per field or globally?

Both, for different purposes. Global isPristine controls form-level actions: disabling the Save button, showing a “you have unsaved changes” banner, or gating navigation away from the page. Field-level computed flags let you defer validation messages until a specific field has been touched, avoiding an error-heavy UI on first render.

How do async form loads affect pristine evaluation?

If you update only current after a fetch, the form immediately reads as dirty because baseline still holds the empty initial values. Call updateBaseline(fetchedData) to replace both refs atomically in the same synchronous call. The form will then read as pristine as soon as the hydration promise resolves.

When should I use watch instead of computed for pristine evaluation?

Almost never for the isPristine flag itself. computed caches the result and only re-evaluates when tracked dependencies actually change. A deep watcher fires on every mutation cycle regardless of whether the evaluated value changed, which wastes CPU during rapid keystrokes on large form payloads. Reserve watch for side effects — for example, persisting a draft to localStorage when the form becomes dirty — and even then use debouncing.


Related

Dirty and Pristine State Tracking