sveltejs / sveltejs/kit

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

Open
#16,015 1 comment 12 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

forms
Dominant language
JavaScript
Stars
20.8k
Forks
2.3k
Avg merge
1d 16h
Merged PRs (30d)
156

Description

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
  • #15937 — Number field .as('number', 200) prevents clearing (confirmed bug)
  • #15707 / PR #15690 — fields.value() returns undefined when input_value passed to .as() (PR stalled — maintainer notes "points at larger issue with initial state")
  • #15249 / PRs #15859, #15681 — RemoteFormInput type rejects null and undefined
  • #14990 — Missing field for form doesn't report error (exactly Direction 2)
  • #15833 — Array proxies not iterable
  • #14647 — Can't enumerate form field proxies
  • #15887 — as("hidden") requires redundant explicit value
  • #14676 — Field value initially empty
  • #14751 — Select elements don't update when value changes
  • #15835 — <select> needs two clicks with remote form
  • #14815 — Remote form factory with initialData (open 8 months, 27👍/18❤️)
  • #14779 — Programmatic form.reset() (open 8 months, 29❤️/28🚀)
Related open PRs with active maintainer engagement
  • #15979 — Rich Harris (DRAFT): set submit field values
  • #15970 — ottomated: return data to enhance callback
  • #15690 — Make value() reflect input_value from .as() (stalled on SSR concerns)
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 #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>.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at the remote form submission path, specifically handle_submit, and inspect how fields.value() and the submitted FormData are available before sending. Compare the two representations for missing or differing fields and define the development-mode warning behavior shown in the issue. Done means the consistency cases are detected without changing production submission behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, typescript
Domain
frontend, web-dev
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.