SvelteKit Form Actions bound to TypeScript class + Validation (yup) w/dynamic array.

Last year, i wrote some posts using Svelte w/a forms lib + validation. Now that SvelteKit 1.0 is out, it includes new Form features, specifically Form Actions, which is a great way to do form handling in a very natural way for web browsers and web apps.

Here, i’m intending to build a proof-of-concept which does the following:

  • Use the Form Actions feature in /Kit
  • Create a TS class which maps to what the formData will contain
  • Use the yup validation library for validating the form submission
  • on failure of validation, return the error plus the form data object back to re-render the field in the form already entered, so the user doesn’t have to re-enter
  • use:enhance support for non-JS web clients

Although this is based on my prior Svelte Form posts, i took influence from WebJeda’s video on SvelteKit forms and validation. In my case, i go an extra step to implement a dynamic form with arrays.

Architecture

create +page.svelte for page w/form (create/update)

create +page.server.js for handling the Form Actions

create a class which maps to the form values: ProfileFormData.ts

create validation classes for use in the form actions ‘create’ | ‘update’ before the mutation in the hypothetical data store.

Validation errors get returned back to the +page.svelte form for redisplay

dynamic array in the FormData object will add form fields and support validation

I’m not going to use a form library, only native SvelteKit features, such as form actions, PageData load() from *.server.ts, bind:value on <input>, and the reactive $: variables.

+page.svelte

The +page.svelte is the heart of the functionality. Under SvelteKit, it will run both client-side JS and server-side JS.

Since we define a matching +page.server.ts, that will run first – server-side only. If one of the form actions return, the +page.svelte receives as form ActionData property.

/** @type {import('./$types').ActionData} */
export let form;

If this is a first page load, nothing is in the form property, so we create a new ProfileFormData() to render the HTML form on-screen.

But if this is either a re-render after a invalid ‘create’ form submit, or if it is an ‘update’ form after loading the record from the data source, the form.formValue contains the structure of the form to render, as well as the data values to populate it.

    // formDefault must match the shape of the form to be rendered 
    // and also fill in the defaults or the prior form submission w/ validation errors
    $: formDefault = setFormDefault(form?.formValue);

    // $: console.log('formDefault ', formDefault);
    // $: console.log('form.formValue changed ', form?.formValue);
    
    function setFormDefault(formValue) {
        if(formValue) {
            console.log('setFormDefault() from form.formValue');
            console.log('formValue: ', formValue);
            return formValue;
        }
        else {
            console.log('setFormDefault() from new ProfileFormData()');
            return new ProfileFormData();
        }
    }

+page.server.ts

The +page.server.ts is dedicated primarily for handling the SvelteKit Form Actions. In my case, i define two – one for ‘create’ and one for ‘update’. I will handle both cases in the +page.svelte <form>, since most fields are shared.

flat form parameter list to unflatten() JS nested object

I do use the unflatten() function from the ‘flat’ package. This allows for mapping a “flat” parameter list in formData() like:

{
  fullname: 'the name',
  email: 'asdf@asdf.d',
  'profile.address': '',
  'contacts.0.contacttype': 'atype',
  'contacts.0.name': 'dsfg',
  'contacts.1.contacttype': 'btype',
  'contacts.1.name': '',
  submit: 'submit button'
}

… to a nested data structure, for validation and persistence.

{
  fullname: 'the name',
  email: 'asdf@asdf.d',
  profile: { address: '' },
  contacts: [
    { contacttype: 'atype', name: 'dsfg' },
    { contacttype: 'btype', name: '' }
  ],
  submit: 'submit button'
}

When the form input elements are written out, i have to construct the names to match the array index format, such are contacts.0.propertyname, contacts.1.propertyname.

{#each formDefault.contacts as c, idx}
                <div>
                    <label for="contacts.{idx}.contacttype">contact type </label>
                    <input
                        type="text"
                        name="contacts.{idx}.contacttype"
                        class=""
                        placeholder="contact type"
                        bind:value={formDefault.contacts[idx].contacttype}
                    />
                    {#if form?.errors?.[`contacts[${idx}].contacttype`]}
                    <span class="error-text">{form?.errors?.[`contacts[${idx}].contacttype`]}</span>
                    {/if}
                </div>

At the form POST handler in +page.server.ts, they get mapped back to a real JS array. After which, the object can be validated. If it passes, save to the data store. If it fails validation, return error status, plus the list of validation errors, as well as the form object data, so it may be redisplayed in the form fields without requiring re-entry.

// formValue will model the form rendering
// it could be a default value, or else a validation error response to modify and resubmit
let formValue: {[key: string]: string};

export const actions: Actions = {
  create: async ({ cookies, request }) => {
    console.log("action: create");
    const fd = await request.formData();
    console.log("fd.forEach() ");
    fd.forEach((val, key) => {
      console.log(`${key}: `, val);
    });
    //formValue = formDataToProfileData(fd);
    formValue = formDataToFormValue(fd);
    console.log("formValue ", formValue);
    console.log("flatten(formValue) ", flatten(formValue));
    console.log("unflatten(formValue) ", unflatten(flatten(formValue)));

    // do create
    try {
      const result = await profileFormDataSchema.validate(formValue, validateOptions);
    }
    catch(error) {
      console.log('error: ', error);
      console.log('error.value: ', error.value);
      const errors = error.inner.reduce((acc, err) => {
        return { ...acc, [err.path]: err.message };
      }, {});
      console.log('errors: ', errors);

      return {
        status: 'error',
        errors: errors,
        formValue: {...error.value},
      };
    }
     
    return { 
      status: 'inserted',
      formValue,
    };
  },

Challenges in mutating the JS object arrays vs. copying them (bind:value problems)

One of the problems in binding the dynamic array form elements to their matching JS object array elements is that i couldn’t recreate the formDefault.contacts = [] array without losing the reference in <input bind:value={formDefault.contacts[idx].contacttype} />

Once i create the form object with the contacts array, i needed to keep the reference and not bash/overwrite it. So when implementing the [add contact] and delete contact [X] links, i use the array.push() and array.splice() methods, to modify the original reference only.

<a href="#" class="btn" on:click|preventDefault={() => {
    console.log('add ');
    console.log('formDefault.contacts = ', formDefault.contacts);
    formDefault.contacts.push({ contacttype: '', name: '', });
    formDefault.contacts = formDefault.contacts;
}}>add a contact</a>
<a href="#" class="btn" on:click|preventDefault={() => {
    console.log('del ', idx);
    formDefault.contacts.splice(idx, 1);
    formDefault.contacts = formDefault.contacts;
    console.log('formDefault.contacts: ', formDefault.contacts);
}}>[X]</a>

use:enhance

With a little testing, this code works with the progressive enhancement / use:enhance features on the forms. Just disable javascript and test.

Running example

Outcomes

Working on this, i hit a number of stumbling blocks. However, it led me to a better understanding of the SvelteKit 1.0 conventions and details.

I’m also confident in using the framework form actions.

References

Source code for this article:
https://github.com/nohea/enehana-sveltekit-form-actions-yup

Example app deployed to Vercel:
https://enehana-sveltekit-form-actions-yup.vercel.app/

WebJeda video on Form validation using Yup in Sveltekit
https://youtu.be/sTLwZc09FWw

Leave a Reply

Your email address will not be published. Required fields are marked *