Skip to content

Remote form fields.value() diverges from submitted FormData — proposal for dev-mode consistency check #16015

Description

@Stadly

Describe the problem

When using remote forms, there are two representations of "the form's data" that can silently diverge:

  1. Internal state — what fields.value() returns (starts as {})
  2. Submitted data — what the server actually receives (serialized from DOM elements into FormData)

These two should always agree. In practice, they frequently don't — and the current API provides no mechanism to detect or prevent this divergence.

Direction 1: DOM has a value, internal state doesn't

<form {...form}>
  <!-- DOM shows "200", server receives "200", but fields.price.value() is undefined -->
  <input {...form.fields.price.as('number', 200)} />

  <!-- Client-side logic breaks because value() doesn't reflect what's visible -->
  {#if form.fields.price.value() !== undefined}
    <p>Price: {form.fields.price.value()}</p> <!-- Never renders! -->
  {/if}
</form>

.as('number', 200) sets the DOM's defaultValue to 200, but the internal state (let input = $state({})) is never updated. The user sees "200", the server receives "200", but any client-side logic using value() sees undefined.

This is the same root cause as #15937 — where clearing a number field is impossible because the form re-asserts the defaultValue.

Direction 2: Internal state has a value, DOM doesn't

<script>
  // Developer correctly initializes the field...
  form.fields.itemId.set("existing-id");

  // ...but forgets to render a corresponding input
</script>

<form {...form}>
  <input {...form.fields.name.as('text')} />
  <!-- No <input {...form.fields.itemId.as("hidden")} /> — silent data loss! -->
  <button>Save</button>
</form>

The server never receives itemId. No error, no warning. The developer thinks they're submitting it (they even called .set() correctly), but it disappears because there's no DOM element to serialize into FormData.

This is exactly the scenario reported in #14990 — "if you forget to add {...form.fields.field.as(...)} for a required field, nothing will happen at all. No errors will get thrown, no warnings."

Real-world consequences

In our production app (~30 remote forms), we've accumulated these workarounds:

// 1. Schema defaults don't propagate to client state — must imperatively .set()
form.fields.versions.set([]);
form.fields.majorVersions.set([]);

// 2. RemoteFormInput doesn't accept `undefined` in its index signature.
//    Schema with .default([]) produces input type `T[] | undefined`, which fails form().
//    Workaround: cast away the default's effect on the input type (loses type safety)
majorVersions: majorVersionDraftsSchema.default([]) as unknown as typeof majorVersionDraftsSchema

// 3. Array iteration requires length check + filter (proxies aren't iterable),
//    AND schema.parse() to narrow DeepPartial<T> back to T
new Array(form.fields.versions.value().length)
  .fill(null, 0)
  .map((_, i) => form.fields.versions[i])
  .filter(v => v !== undefined)
  .map(field => ({ field, value: versionDraftSchema.parse(field.value()) }))

// 4. Edit forms need .set() at top-level for SSR AND inside $effect.pre for reactivity
// svelte-ignore state_referenced_locally
form.fields.set(app);
$effect.pre(() => { form.fields.set(app); });

Each workaround exists because the form's internal state and its DOM representation are independently managed with no synchronization.

The DeepPartial type problem (workaround #3)

Because internal state starts as {}, value() must return DeepPartial<T> — every property is recursively optional:

// The schema type:
type Version = { number: number; changeLog: string; majorVersion: number }

// What value() actually returns:
type FromValue = { number?: number; changeLog?: string; majorVersion?: number } | undefined

This makes value() results unusable anywhere the full schema type is expected. The workaround is calling schema.parse(field.value()) — which validates AND narrows the type back to T. This adds runtime overhead and boilerplate for what is fundamentally a type-level consequence of the empty-initialization design.

If the form guaranteed complete initialization (from schema defaults or mandatory initial values), value() could return T directly — eliminating both the type unsafety and the need for runtime re-parsing.

The architectural issue

The current design stores form state in let input = $state({}) which starts empty. When .as(type, defaultValue) is called, it sets the DOM defaultValue attribute but does not update input. The value() method reads exclusively from input. This creates a persistent split:

  • value() returns undefined for every field until explicitly .set()
  • The DOM shows whatever defaultValue was passed to .as()
  • The server receives whatever the DOM serializes
  • The TypeScript return type must be DeepPartial<T> to reflect this uncertainty

Proposed: dev-mode consistency check

During development, when a form is submitted, compare the internal state (fields.value()) with the FormData being sent. If they diverge, emit a console warning:

Direction 1 (DOM → state):

⚠️ [svelte-kit] Form field "price" has value `undefined` in fields.value()
but will be submitted as "200" (from DOM defaultValue).
This can cause issues if you rely on fields.value() for client-side logic.
Consider calling fields.price.set(200) to synchronize.

Direction 2 (state → DOM):

⚠️ [svelte-kit] Form field "itemId" has value "existing-id" in fields.value()
but no corresponding <input> was found in the submitted FormData.
Did you forget to add <input {...form.fields.itemId.as("hidden")} />?

Summary:

fields.value() FormData (submitted) Result
undefined "200" sent ⚠️ Forgot .set() — client logic sees stale state
"existing-id" field missing ⚠️ Forgot <input> — server never receives it
"hello" "hello" ✅ Consistent

This is low-cost to implement (a comparison in handle_submit before sending), non-breaking, and would catch an entire class of bugs at development time. It also buys time to figure out the larger architectural question of how initialization should work.

Related open issues

Related open PRs with active maintainer engagement

v3 milestone

The v3 milestone is ~56% complete (49 open / 63 closed) but contains no remote form issues or PRs. Given that elliott acknowledged "this points at some larger issue we need to solve around how the 'initial state' of forms works" (#15690, Apr 21), and Rich Harris is actively working on form PRs (#15979):

Is there a plan to address the state/DOM divergence as part of v3? If so, the dev-mode check could serve as a bridge. If not, it would provide permanent protection against this class of bugs.

Questions for maintainers

  1. Is the value() / FormData divergence considered a bug, or intentional design? (The stalled PR fix: make form fields.value() reflect input_value passed to .as() #15690 suggests it's acknowledged but unresolved.)
  2. Would a dev-mode consistency check be welcome as a PR, or is there a broader refactor planned that would make it obsolete?
  3. Should form() eventually guarantee that fields.value() always reflects what would be submitted — making it the single source of truth? This would also allow value() to return T instead of DeepPartial<T>.

Activity

  1. Nic-Polumeyv commented on Jul 18, 2026

    @Nic-Polumeyv
    Member

    #15908 and #16359 look like live instances of Direction 1 here, the submitter button's value in one case and untouched or programmatically set fields in the other, both present in the submitted FormData but never entering the fields state.

  2. added
    formsStuff relating to forms and form actions
    and removed on Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    formsStuff relating to forms and form actions

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions