iTranslated by AI
The Struggles with Svelte $state Proxy and use:enhance
Introduction
I attempted to add a boilerplate reduction feature to @svelte-ssv/core, the lightweight form validation OSS I introduced in my previous article. While what I wanted to achieve was simple, I ended up struggling with the combination of Svelte 5's $state Proxy and SvelteKit's use:enhance.
1. What I Wanted to Achieve
When using createForm and createEnhanceHandler in SvelteKit, you need the following plumbing for every form:
let form = $state(createForm(schema, initial));
const handleEnhance = createEnhanceHandler(form.validator, {
getData: () => form.data,
setErrors: (e) => {
const keys = Object.keys(form.data);
for (const key of keys) form.touched[key] = true;
form.errors = e;
},
onSuccess: () => closeDialog(),
});
The wiring for getData, setErrors, and form.validator is identical every time. I wanted to consolidate this into a single call.
// Ideal: Everything set up with just this
let form = $state(createEnhanceForm(schema, {
initial: { name: '', email: '' },
onSuccess: () => closeDialog(),
}));
<form method="POST" novalidate use:enhance={form.enhance}>
2. The Wall Between $state Proxy and use:enhance
Why createEnhanceForm Didn't Work
When I implemented it, I ran into an issue where validation would always run with empty data upon submission, even after entering values into the form.
Tracing the cause led me to how $state works.
let form = $state(createEnhanceForm(schema, options));
What happens in this one line:
1. createEnhanceForm(schema, options) is executed
→ Internally calls createEnhanceHandler, creating a closure for getData
→ At this point, form is a "plain JavaScript object"
2. $state() wraps that object in a Proxy
→ From then on, template bind:value writes via the Proxy
3. User enters input
→ bind:value={form.data.name} → Proxy.data.name = "Taro"
→ Written to the Proxy's target (= the object internally held by $state)
4. submit → use:enhance → getData() is called
→ getData is the closure created in step 1
→ The form referenced by the closure is the "plain object" from step 1
→ A different reference than the Proxy target
→ getData() always returns the initial values
Diagram:
$state(createEnhanceForm(...))
^^^^^^^^^^^^^^^^^^^^^^^^
enhance handler created here
→ closure references raw object
$state() wraps raw object in Proxy
→ Proxy target ≠ closure's raw object ← This is the problem
Why createForm Methods Work
form.blur("name") and form.validate() work fine. The reason is this binding.
// When you write form.blur("name"):
// JavaScript binds this = form (= Proxy)
// this.data inside blur is via Proxy → reads the latest value
blur(field) {
this.touched[field] = true;
this.data[field] ... // this is the Proxy → latest value
}
form.blur("name") resolves this at the time of the call. Once you write form., this becomes the Proxy.
On the other hand, getData in createEnhanceHandler is a closure:
getData: () => form.data // form is the value captured by the closure
A closure determines its references at the time of creation. Since it was created inside $state(), form is the raw object.
| Resolution Timing | Via Proxy? | |
|---|---|---|
form.blur("name") |
At call time (this binding) |
✅ |
form.validate() |
At call time (this binding) |
✅ |
getData: () => form.data |
At creation time (closure capture) | ❌ (if before $state) |
Trial and Error Log
| Attempt | Approach | Result |
|---|---|---|
| 1 | Reference raw object in closure |
getData() always returns initial value |
| 2 | Make enhance a this-based method |
use:enhance doesn't bind this → this === undefined
|
| 3 | Arrow function + copy with Object.getOwnPropertyDescriptors
|
Copy and closure are different objects → same issue |
| 4 | Generate createEnhanceHandler every time in getter |
Conflicts with isDirty getter in tests |
All failed. Fundamentally, you cannot create use:enhance callbacks within the arguments of $state().
3. Solution: buildEnhanceHandler — Syntactic Sugar to Call After $state
I gave up on createEnhanceForm and redesigned it as a function to be called after $state.
import { createForm } from '@svelte-ssv/core/form';
import { buildEnhanceHandler } from '@svelte-ssv/core/enhance';
let form = $state(createForm(schema, initial)); // ← Proxy created here
// buildEnhanceHandler is called after $state
// form is already a Proxy → getData: () => form.data is via the Proxy
const handleEnhance = buildEnhanceHandler(form, {
onSuccess: () => closeDialog(),
});
The internal implementation of buildEnhanceHandler is simple; it is just a wrapper for createEnhanceHandler:
export function buildEnhanceHandler(form, options) {
return createEnhanceHandler(form.validator, {
getData: () => form.data, // form is the Proxy
setErrors: (e) => {
for (const key of Object.keys(form.data)) {
form.touched[key] = true; // form is the Proxy
}
form.errors = e; // form is the Proxy
},
onSuccess: options?.onSuccess,
...
});
}
Comparison with createEnhanceHandler
// createEnhanceHandler: Writing the plumbing yourself (7 lines)
const handleEnhance = createEnhanceHandler(form.validator, {
getData: () => form.data,
setErrors: (e) => {
const keys = Object.keys(form.data);
for (const key of keys) form.touched[key] = true;
form.errors = e;
},
onSuccess: () => closeDialog(),
});
// buildEnhanceHandler: Automated plumbing (3 lines)
const handleEnhance = buildEnhanceHandler(form, {
onSuccess: () => closeDialog(),
});
createEnhanceHandler |
buildEnhanceHandler |
|
|---|---|---|
getData |
Manual | Automated |
setErrors |
Manual (customizable) | Automated (touched mark + errors set) |
form.validator |
Explicitly passed | Automatically retrieved from form |
| Custom display (Toast / Summary) | Possible | Not possible → Use createEnhanceHandler
|
buildEnhanceHandler is strictly syntactic sugar. If custom logic is required for setErrors (such as toast notifications or error summaries), you should continue to use the original createEnhanceHandler.
Appendix: populate() — A Feature Born as a Byproduct of the Proxy Issue
The previous discussion focused on the battle between use:enhance and the $state Proxy. While populate() is not directly related to that battle, it is a byproduct born from the same development cycle.
Why I Tried to Create populate()
Originally, the vision for createEnhanceForm included reducing boilerplate for CRUD edit dialogs as well. If I could combine the form and enhance into one with createEnhanceForm and include existing data loading in the form with populate(), it would significantly reduce the wiring for modal CRUDs—that was the goal.
Although createEnhanceForm ended in failure, populate() itself is independently useful as a createForm method, so I left it in.
Code Without populate()
let form = $state(createForm(schema, { name: '', email: '', role: '' }));
let editTarget = $state(null);
function openEdit(user) {
editTarget = user;
// Manually copy fields one by one
form.data.name = user.name;
form.data.email = user.email;
form.data.role = user.role;
// Manually clear errors and touched
form.errors = {};
form.touched.name = false;
form.touched.email = false;
form.touched.role = false;
// dirty is... isDirty will become true (baseline remains as empty string)
showDialog = true;
}
function openCreate() {
editTarget = null;
form.data.name = '';
form.data.email = '';
form.data.role = '';
form.errors = {};
form.touched.name = false;
form.touched.email = false;
form.touched.role = false;
showDialog = true;
}
The more fields there are, the more the code balloons. Additionally, there is an issue where isDirty behaves unexpectedly. Even if values are set with form.data.name = user.name, the baseline for dirty tracking (the empty string passed to createForm) does not change, causing isDirty to be true immediately after opening.
Code With populate()
let form = $state(createForm(schema, { name: '', email: '', role: '' }));
let editTarget = $state(null);
function openEdit(user) {
editTarget = user;
form.populate({ name: user.name, email: user.email, role: user.role });
// data is set, errors / touched / dirty are cleared,
// and dirty baseline is updated → isDirty === false
showDialog = true;
}
function openCreate() {
editTarget = null;
form.populate({ name: '', email: '', role: '' });
showDialog = true;
}
Difference from reset()
const form = createForm(schema, { name: '', email: '' });
form.populate({ name: 'Taro', email: 'taro@example.com' });
// data = { name: 'Taro', ... }
// isDirty = false (baseline updated to Taro)
form.data.name = 'Changed';
// isDirty = true
form.reset();
// data = { name: 'Taro', ... } (reverts to value at populate, not the empty string)
reset() reverts to the "last populated value." When you press the Undo button in an edit dialog, it returns to the value loaded from the server.
Other Use Cases for populate()
Step Forms: Share a single form instance across steps. When returning to a previous step with the Back button, populate() restores the step's data while clearing touched/errors. Since it accepts Partial<T>, you can also override fields for specific steps.
Inline Table Editing: A pattern where clicking a row switches it to inputs. When switching to another row, recreating the form causes the DOM to be rebuilt, but populate() only replaces the properties of the same object, keeping DOM diffs to a minimum.
Conclusion
- Creating
use:enhancecallbacks inside$state()causes the closure to capture the raw object, breaking functionality. -
this-based methods likeform.blur()work via the Proxy, but closure-basedgetDatamust be created after$state. -
buildEnhanceHandlerautomates wiring boilerplate (requires calling after$state). -
populate()supports CRUD editing, step forms, and inline table editing. - Source code: GitHub / npm: @svelte-ssv/core
Discussion