Svelte Complex Forms, part 3 – components using get/setContext() for less passing props

In my prior two posts, i created a more complex dynamic and hierarchical form, including radio buttons, and extracted the “sveltey” html into a couple components.

In this post, i’m refining my components a bit, to reduce the boilerplate attributes needed for each instance. Currently, they look like this:

<ZInput
	nameAttr="fullname"
	nameLabel="Full Name"
	bindValue={$form.fullname}
	errorText={$errors?.fullname}
	{handleChange}
/>

<ZRadio
	nameAttr="prefix"
	nameLabel="Prefix"
	itemList={prefixOptions}
	itemValueChecked="n/a"
	errorText={$errors?.prefix}
	{handleChange}></ZRadio>

I would like to remove the errorText and handleChange props, even the bindValue if possible.

To bypass explicit props just to reference the $errors object and the handleChange functions, i’ll use Svelte’s getContext() and setContext(). I borrowed it from the svelte-forms-lib optional components, but mine are different in that they also include a <label> and $errors indicator. For a great explanation of Svelte Context, see Tan Li Hau’s Store vs Context tweet/video.

In the form.svelte page, it contains the bare <form> element, and the createForm() call which returns $form, $errors, and the handle* functions. We add new setContext() call, setting those objects. This will allow child components to getContext() and access the same objects, without requiring them to be passed in via props.

const { form, errors, state, handleChange, handleSubmit, handleReset } 
  = createForm(formProps);

// allows for referencing the internal $form and $errors from this page, 
// so we can add the array handlers
setContext(key, {
	form,
	errors,
	handleChange,
	handleSubmit,
});

Now in the child components, add getContext() and modify the use.

ZInput <script> section:

// allows the Form* components to share state with the parent form
const { form, errors, handleChange } = getContext(key);

Then we won’t have to pass in those objects via prop. However, we still have a problem…

Impedance-mismatch: flat $errors keys vs. hierarchical $forms object

As discussed in my prior post, the underlying $form store is “flat”, but the underlying svelte store is hierarchical, meaning we have to refer to $form by that flat key. For example:

$form$errors
$form[‘fullname’]$errors.fullname
$form[‘profile.address’]$errors.profile.address

$form[‘contacts[0].name’]

$errors.contacts[0].name
$form[‘contacts[2].name’]$errors.contacts[2].name

I think this is the inevitable mismatch where the html form has a list of flat “name”s in a traditional POST, but the data it is handling is hierarchical.

If we try to use the key passed in for “name”, it won’t work to find the matching error message. So we have to convert the string key “contacts[2].name” to the equivalent object reference $errors.contacts[2].name in order to display the linked error message (if any).

I ended up using a function from stack overflow, which allows me to pass in an object, and a string key, which returns the value from the object the string key would point to:

// window.a = {b: {c: {d: {etc: 'success'}}}}
// getScopedObj(window, `a.b.c.d.etc`)             // success
// getScopedObj(window, `a['b']["c"].d.etc`)       // success
// getScopedObj(window, `a['INVALID']["c"].d.etc`) // undefined
export function getScopedObj(scope, str) {
    // console.log(`getScopedObj(scope, ${str})`);
    let obj = scope, arr;

    try {
        arr = str.split(/[\[\]\.]/) // split by [,],.
            .filter(el => el)             // filter out empty one
            .map(el => el.replace(/^['"]+|['"]+$/g, '')); // remove string quotation
        arr.forEach(el => obj = obj[el])
    } catch (e) {
        obj = undefined;
    }

    return obj;
}

Then my final component looks like this:

<script>
    import { getScopedObj } from "$lib/util";
    import { getContext } from "svelte";
    import { key } from "svelte-forms-lib";
    export let nameAttr;
    export let nameLabel;
    export let bindValue;
    // allows the Form* components to share state with the parent form
    const { form, errors, handleChange } = getContext(key);
</script>
<div>
    <label for={nameAttr}>{nameLabel}</label>
    <input
        placeholder={nameLabel}
        name={nameAttr}
        on:change={handleChange}
        on:blur={handleChange}
        bind:value={bindValue}
    />
    {#if getScopedObj($errors, nameAttr)}
        <div class="form-error">{getScopedObj($errors, nameAttr)}</div>
    {/if}
</div>

<ZRadio> can be modified in a similar way. Now the component tags are more concise:

<ZInput
	nameAttr={`contacts[${j}].email`}
	nameLabel="Email"
	bindValue={$form.contacts[j].email}
	/>

<ZRadio
	nameAttr={`contacts[${j}].contacttype`}
	nameLabel="Contact Type"
	itemList={contactTypes}
	itemValueChecked="n/a"
	/>

… and the user behavior is the same.

code for part 3 is at:

https://github.com/nohea/enehana-complex-svelte-form/tree/part3

Leave a Reply

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