iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🪤

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 thisthis === 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:enhance callbacks inside $state() causes the closure to capture the raw object, breaking functionality.
  • this-based methods like form.blur() work via the Proxy, but closure-based getData must be created after $state.
  • buildEnhanceHandler automates wiring boilerplate (requires calling after $state).
  • populate() supports CRUD editing, step forms, and inline table editing.
  • Source code: GitHub / npm: @svelte-ssv/core

Discussion