Remote form `fields.value()` diverges from submitted FormData — proposal for dev-mode consistency check
Nobody has claimed this yet.
- 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:
- Internal state — what
fields.value()returns (starts as{}) - 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()returnsundefinedfor every field until explicitly.set()- The DOM shows whatever
defaultValuewas 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 wheninput_valuepassed to.as()(PR stalled — maintainer notes "points at larger issue with initial state") - #15249 / PRs #15859, #15681 —
RemoteFormInputtype rejectsnullandundefined - #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()reflectinput_valuefrom.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
- Is the
value()/ FormData divergence considered a bug, or intentional design? (The stalled PR #15690 suggests it's acknowledged but unresolved.) - Would a dev-mode consistency check be welcome as a PR, or is there a broader refactor planned that would make it obsolete?
- Should
form()eventually guarantee thatfields.value()always reflects what would be submitted — making it the single source of truth? This would also allowvalue()to returnTinstead ofDeepPartial<T>.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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