The specific production failure this page addresses: async field validators that resolve out of order, leaving VALID stamped on a field whose value has already changed β€” a race condition that Svelte’s reactive primitives do not prevent on their own. This pattern applies whenever a Svelte form touches a remote API (email uniqueness checks, username lookups, postcode lookups) and must display accurate, real-time feedback without committing stale results.

This is a sub-topic within Framework Adapters & Custom Hooks. The broader pipeline context β€” how validation status flows up from field to form to submit gate β€” is covered in the form validation lifecycle reference.


State machine specification

Each field tracks exactly one of five states. Triggers move the field forward or backward; no state is skipped.

State Meaning Typical trigger
IDLE No user interaction yet Store initialisation / resetForm()
PENDING Debounce timer running; no result yet input event fires
VALIDATING Async request in flight Debounce timer expires
VALID Last check passed Async resolver commits null error
INVALID Last check failed Resolver commits non-null error string

The form itself aggregates field states: it is SUBMITTABLE only when every field is VALID and isSubmitting is false.

Svelte form field state machine State transitions for a single form field: IDLE β†’ PENDING β†’ VALIDATING β†’ VALID or INVALID, with reset back to IDLE IDLE PENDING VALIDATING VALID INVALID input debounce end null error error string reset re-input

Core implementation

The implementation below is fully runnable TypeScript. Key design decisions:

  • An AbortController is created per validation invocation and cancelled on every subsequent call β€” this is the only reliable guard against out-of-order async results. A debounce timer alone is not sufficient.
  • get(formStore) reads the store synchronously inside the debounce callback to capture the stabilised value, not the value at keystroke time.
  • The derived store re-computes on every formStore update, keeping the submit gate reactive without manual wiring.
import { writable, derived, get } from 'svelte/store';

// ── Types ────────────────────────────────────────────────────────────────────

export type ValidationStatus = 'IDLE' | 'PENDING' | 'VALIDATING' | 'VALID' | 'INVALID';

export interface FormField<T = string> {
  value: T;
  error: string | null;
  touched: boolean;
  status: ValidationStatus;
}

export interface FormState {
  email: FormField;
  username: FormField;
  isSubmitting: boolean;
}

// ── Initial state ─────────────────────────────────────────────────────────────

const blankField = (): FormField => ({
  value: '',
  error: null,
  touched: false,
  status: 'IDLE',
});

const initialState: FormState = {
  email: blankField(),
  username: blankField(),
  isSubmitting: false,
};

// ── Store ─────────────────────────────────────────────────────────────────────

export const formStore = writable<FormState>(initialState);

// ── Debounce factory ──────────────────────────────────────────────────────────

function makeDebounce(ms: number) {
  let timer: ReturnType<typeof setTimeout>;
  return (fn: () => void): void => {
    clearTimeout(timer);
    timer = setTimeout(fn, ms);
  };
}

// ── Async validators ──────────────────────────────────────────────────────────

// AbortController is passed in so the caller controls cancellation.
// Always check signal.aborted before writing results back to the store.
async function checkEmailAvailability(
  value: string,
  signal: AbortSignal   // AbortSignal β€” must be checked before any store.update()
): Promise<string | null> {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
    return 'Enter a valid email address';
  }
  const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, { signal });
  if (signal.aborted) return null;   // race guard β€” discard this result
  const { available } = await res.json();
  return available ? null : 'This email is already registered';
}

async function checkUsernameAvailability(
  value: string,
  signal: AbortSignal   // AbortSignal β€” checked before committing result
): Promise<string | null> {
  if (value.length < 3) return 'Username must be at least 3 characters';
  const res = await fetch(`/api/check-username?username=${encodeURIComponent(value)}`, { signal });
  if (signal.aborted) return null;   // race guard
  const { available } = await res.json();
  return available ? null : 'Username is taken';
}

// ── Dispatcher ────────────────────────────────────────────────────────────────

// One AbortController per field β€” holds the in-flight request for that field.
// WeakMap is NOT appropriate here because field keys are plain strings, not objects.
// A plain Record is correct; it holds exactly one controller per named field.
const controllers: Partial<Record<keyof Omit<FormState, 'isSubmitting'>, AbortController>> = {};

const debounce = makeDebounce(320);

export function updateField(
  field: keyof Omit<FormState, 'isSubmitting'>,
  value: string
): void {
  // 1. Cancel any in-flight request for this field immediately on new input.
  //    This is the race-condition guard β€” debounce alone is not enough.
  controllers[field]?.abort();

  // 2. Mark the field as PENDING so the UI can show a spinner immediately.
  formStore.update(state => ({
    ...state,
    [field]: { ...state[field], value, touched: true, status: 'PENDING' },
  }));

  debounce(async () => {
    // 3. Create a fresh controller for this validation run.
    const controller = new AbortController();
    controllers[field] = controller;   // AbortController β€” stored so next keystroke can cancel it

    // 4. Read the stabilised value after debounce, not the value at keystroke time.
    const stabilisedValue = (get(formStore)[field] as FormField).value;

    // 5. Set VALIDATING so the UI can differentiate "waiting to start" from "request in flight".
    formStore.update(state => ({
      ...state,
      [field]: { ...state[field], status: 'VALIDATING' },
    }));

    try {
      const error = field === 'email'
        ? await checkEmailAvailability(stabilisedValue, controller.signal)
        : await checkUsernameAvailability(stabilisedValue, controller.signal);

      // 6. Only commit if this controller was not superseded by a newer keystroke.
      if (!controller.signal.aborted) {
        formStore.update(state => ({
          ...state,
          [field]: { ...state[field], error, status: error ? 'INVALID' : 'VALID' },
        }));
      }
    } catch (err) {
      // AbortError is expected on cancellation β€” do not write an error to the store.
      if (err instanceof DOMException && err.name === 'AbortError') return;
      formStore.update(state => ({
        ...state,
        [field]: { ...state[field], error: 'Validation failed β€” please retry', status: 'INVALID' },
      }));
    }
  });
}

// ── Derived state ─────────────────────────────────────────────────────────────

// isFormSubmittable re-computes whenever formStore changes.
// VALID on every field AND not currently submitting = gate opens.
export const isFormSubmittable = derived(formStore, $s =>
  (['email', 'username'] as const).every(f => $s[f].status === 'VALID') &&
  !$s.isSubmitting
);

// ── Teardown ──────────────────────────────────────────────────────────────────

/** Call this in onDestroy or on successful navigation away from the form. */
export function destroyFormStore(): void {
  // Cancel any in-flight requests so they cannot commit after the component unmounts.
  Object.values(controllers).forEach(ctrl => ctrl?.abort());
  formStore.set(initialState);
}

export const resetForm = () => formStore.set(initialState);

Component wiring (Svelte 4)

<script lang="ts">
  import { onDestroy } from 'svelte';
  import { formStore, isFormSubmittable, updateField, destroyFormStore } from './formStore';

  // $ auto-subscription β€” compiler inserts unsubscribe in onDestroy automatically
  $: form = $formStore;
  $: canSubmit = $isFormSubmittable;

  onDestroy(destroyFormStore);

  async function handleSubmit() {
    formStore.update(s => ({ ...s, isSubmitting: true }));
    try {
      await fetch('/api/register', {
        method: 'POST',
        body: JSON.stringify({ email: form.email.value, username: form.username.value }),
      });
    } finally {
      formStore.update(s => ({ ...s, isSubmitting: false }));
    }
  }
</script>

<form on:submit|preventDefault={handleSubmit} novalidate>
  <label for="email">Email</label>
  <input
    id="email"
    type="email"
    value={form.email.value}
    data-field="email"
    data-status={form.email.status}
    aria-invalid={form.email.status === 'INVALID'}
    aria-describedby="email-error"
    on:input={e => updateField('email', e.currentTarget.value)}
  />
  <span id="email-error" role="alert" aria-live="polite">
    {#if form.email.status === 'INVALID'}{form.email.error}{/if}
    {#if form.email.status === 'VALIDATING'}Checking…{/if}
  </span>

  <label for="username">Username</label>
  <input
    id="username"
    type="text"
    value={form.username.value}
    data-field="username"
    data-status={form.username.status}
    aria-invalid={form.username.status === 'INVALID'}
    aria-describedby="username-error"
    on:input={e => updateField('username', e.currentTarget.value)}
  />
  <span id="username-error" role="alert" aria-live="polite">
    {#if form.username.status === 'INVALID'}{form.username.error}{/if}
    {#if form.username.status === 'VALIDATING'}Checking…{/if}
  </span>

  <button type="submit" disabled={!canSubmit}>
    {form.isSubmitting ? 'Registering…' : 'Register'}
  </button>
</form>

Integration guidance

This store pattern slots directly into the asynchronous validation strategies pipeline: the VALIDATING state maps to the pipeline’s in-flight phase, and the AbortController cancellation logic is the same mechanism described for cancelling fetch-based uniqueness checks in implementing async email availability checks.

The isFormSubmittable derived store acts as the submit gate described in the form validation lifecycle. Wire it to disabled on the submit button β€” never compute submission eligibility inline in the component, because derived stores are already memoised.

If the form rehydrates after SSR, review handling Svelte form hydration mismatches before wiring store updates to on:input β€” the first hydration event from the browser can replay input values the server already rendered, triggering spurious VALIDATING states.

For dirty and pristine state tracking, compare field.value against the original server-supplied value in a separate derived store rather than inside updateField. Mixing dirty-tracking into the validation dispatcher creates temporal coupling that makes testing harder.


Edge cases & failure modes

Concurrent field updates during rapid tab-navigation

When a user tabs through fields quickly, multiple updateField calls fire for different fields nearly simultaneously. The controllers record holds one AbortController per field name, so cancelling the email controller never affects the username controller. Verify this during QA by tabbing through all fields within 100 ms in a network-throttled environment and confirming each field eventually reaches VALID or INVALID independently.

Store value versus event target value drift

Inside the debounce callback, reading e.currentTarget.value is unsafe β€” the event object may have been recycled or the input unmounted. Always read the stabilised value via get(formStore)[field].value after the debounce fires. The implementation above does this at step 4.

Module-scope store state bleeding between test runs

A module-scope formStore retains its last state across component remounts in the same module context β€” common in Vitest with default module caching. Call resetForm() in beforeEach or use a factory that returns a fresh store per test.

AbortError propagation on older Safari

Safari 14 and below throw DOMException with name === 'AbortError' on aborted fetches, but some polyfill environments throw a plain Error. The catch block above checks err instanceof DOMException && err.name === 'AbortError'. If you support environments where this is unreliable, also check err?.name === 'AbortError' as a fallback.

Shadow DOM event boundary

If the form component is mounted inside a Web Component with a closed shadow root, on:input events bubble only to the shadow host. Svelte’s event directives handle this transparently, but custom addEventListener calls in the store module cannot reach elements inside a closed shadow root. Keep all event binding in the Svelte template, not in the store module.


Troubleshooting reference

Failure scenario Diagnostic step Recovery action
Field stays VALIDATING indefinitely Check Network tab for a pending fetch; confirm the API responds within timeout Add a setTimeout-based timeout that aborts the controller if the request exceeds ~5 s
VALID stamped on wrong value after fast typing Confirm AbortController cancellation fires on every updateField call Ensure controllers[field]?.abort() is called at the top of updateField, before the debounce
Submit button stays disabled after all fields pass Log isFormSubmittable in the browser console; check that every field key is listed in the every() predicate Update the field key list in the derived callback to include all fields
Stale errors visible after navigating back to the form Confirm destroyFormStore or resetForm is called in onDestroy Call resetForm() on route enter if the form should always start blank
Duplicate validation requests firing per keystroke Check that debounce is created once at module scope, not inside the function Move makeDebounce call outside updateField so it shares one timer across all calls

Testing & QA hooks

The data-field and data-status attributes on each <input> provide stable selectors for Playwright and Cypress without coupling tests to CSS class names:

// Playwright β€” wait for async validation to complete before asserting
await page.fill('[data-field="email"]', '[email protected]');
await page.waitForSelector('[data-field="email"][data-status="VALID"]', { timeout: 4000 });

// Cypress
cy.get('[data-field="username"]').type('newuser');
cy.get('[data-field="username"][data-status="VALID"]', { timeout: 4000 }).should('exist');

For accessibility regression coverage, assert that aria-invalid flips to "true" on INVALID and that aria-describedby points to the error <span>. These attributes are the source of truth for screen reader announcements β€” if they are missing, the a11y_check gate will catch it, but catching it in your own test suite is faster.

// Playwright accessibility assertion
await expect(page.locator('[data-field="email"]')).toHaveAttribute('aria-invalid', 'true');
await expect(page.locator('#email-error')).toHaveText(/already registered/);

Common pitfalls

  • Debounce without cancellation. A 320 ms debounce prevents most races but not all. A slow network can still deliver a response from request N after request N+1 has committed. Cancel the in-flight request on every new updateField call; do not rely on debounce alone.
  • Reading event values inside the debounce callback. Svelte’s synthetic event objects can be recycled. Read field values from the store via get(formStore) inside the debounce, not from the closure-captured event argument.
  • Global module-scope store for multi-instance forms. If the same form component is mounted in two places simultaneously (e.g. a modal and an inline widget), they share the same formStore and overwrite each other’s state. Use a context-scoped store factory: setContext('form', writable(initialState)) and getContext('form') per component tree.
  • Forgetting to cancel controllers in onDestroy. If the user navigates away while a request is in flight, the callback fires after the component is gone. Without destroyFormStore(), it calls formStore.update() on a store that may now be powering a different page’s form. Always call destroyFormStore in onDestroy.
  • Not reflecting VALIDATING in the UI. Showing only VALID / INVALID states means users see no feedback during the network round-trip and may submit before the check completes. Render a β€œChecking…” indicator while status === 'VALIDATING' and keep the submit button disabled until all fields reach VALID.

Frequently Asked Questions

How does Svelte store validation differ from hook-based form libraries?

Svelte stores operate at module scope, outside the component tree. Validation logic runs once per store update rather than re-rendering all subscribers. The compiler eliminates subscription boilerplate via the $ syntax, but the module-level architecture means you are responsible for AbortController teardown that a hook library like React Hook Form would handle internally through the hook’s cleanup function.

Can this architecture handle async uniqueness checks without race conditions?

Yes, but only if every async validator carries an AbortController whose signal is checked before committing results. Debounce alone is not sufficient β€” a slow previous request can still resolve after a faster subsequent one. Cancel the in-flight request on every new keystroke by calling controllers[field]?.abort() at the top of updateField.

How do you prevent store subscription leaks in SvelteKit routing?

Use the $store auto-subscription syntax in component templates β€” the Svelte compiler inserts the unsubscribe call inside onDestroy automatically. For programmatic subscriptions in utility modules, capture the returned unsubscribe function and call it explicitly inside onDestroy. Also call destroyFormStore() to cancel any in-flight HTTP requests that would otherwise write to the store after the component is gone.

When should the form store live at module scope versus inside a component?

Module-scope stores suit multi-step wizards and cross-route state where you need field values to survive navigation. If the form is self-contained and must reset on unmount, instantiate the store inside a Svelte context (setContext / getContext) or as a component-local variable to prevent state bleed between independent instances of the same form component.


Related

← Framework Adapters & Custom Hooks