DEV Community

Bob
Bob

Posted on • Updated on

Multi-step forms w/ Svelte & TypeScript

Setup

  • Svelte v3.29.7
  • TypeScript v^4.0.5

Introduction

I have been working on a project the last few months that has required the extensive creation of forms, and have been using a similar method to this to break them down. Multi-step forms allow for greater user interactivity as well as a smoother UX.

I will show you the steps I took to create generic forms, then the ones I took to create multi-step forms, and finally the ones I took to create multi-page forms.

Here's the repo to follow along: Repo

Step 1: Generic Single Step Form

Let's begin with this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Static HTML Form</title>
  </head>
  <body>
    <form>
      <h2>Customer Info</h2>

      <input type="text" name="first_name" placeholder="First Name" />
      <input type="text" name="last_name" placeholder="Last Name" />
      <input type="text" name="phone" placeholder="Phone Number" />
      <input type="email" name="email" placeholder="Email" />

      <h2>Billing Info</h2>

      <input type="text" name="address" placeholder="Address" />
      <input type="text" name="address2" placeholder="Address 2" />
      <input type="text" name="city" placeholder="City" />
      <select name="state">
        <option>State</option>

        <!-- States List -->
      </select>
      <input type="text" name="zip" placeholder="Zip Code" />

      <h2>Shipping Info</h2>

      <input type="text" name="address" placeholder="Address" />
      <input type="text" name="address2" placeholder="Address 2" />
      <input type="text" name="city" placeholder="City" />
      <select name="state">
        <option>State</option>

        <!-- States List -->
      </select>
      <input type="text" name="zip" placeholder="Zip Code" />

      <h2>Payment Info</h2>

      <input type="text" name="card" placeholder="Card Number" />
      <input type="text" name="month" placeholder="Month" />
      <input type="text" name="year" placeholder="Year" />
      <input type="text" name="cvv" placeholder="CVV" />
      <input type="text" name="card_zip" placeholder="Zip Code (optional)" />

      <br />

      <input type="submit" placeholder="Submit" />
    </form>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

We're missing a few things, such as name info for billing & shipping, but we'll ignore that for this example. This is going to look quite ugly out the gate, and it's not user friendly to say the least. Let's add Svelte to the project, and add some behind the scenes UX.

Step 2: Introducing Svelte to Our Form

Here are our core files:

index.ts

import App from "./App.svelte";

const app = new App({
  target: document.body,
});

export default app;
Enter fullscreen mode Exit fullscreen mode

types.ts

export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

export type JsonString = { [key: string]: string };
Enter fullscreen mode Exit fullscreen mode

localStore.ts

import { writable, get } from "svelte/store";

import type { JsonValue } from "./types"

export function local<T extends JsonValue>(key: string, initial: T) {
  const toString = (value: T) => JSON.stringify(value, null, 2); // helper function
  const toObj = JSON.parse; // helper function

  if (localStorage) {
    if (localStorage.getItem(key) === null) {
      localStorage.setItem(key, toString(initial));
    }

    const saved = toObj(localStorage.getItem(key) || '');

    const store = writable(saved);

    return {
      subscribe: store.subscribe,
      set: (value: T) => {
        localStorage.setItem(key, toString(value));

        store.set(value);
      },
      update: (fn: (value: T) => T) => {
        store.update(fn);

        localStorage.setItem(key, toString(get(store)));
      }
    };
  } else {
    return writable(initial);
  }
};
Enter fullscreen mode Exit fullscreen mode

App.svelte

<script lang="ts">
  import states from "./us_states";

  import Form from "./components/Form.svelte";
  import Input from "./components/Input.svelte";
  import Select from "./components/Select.svelte";

  const name = "order";
</script>

<main>
    <Form {name} let:store>
    <h2>Customer Info</h2>

    <Input {store} type="text" name="first_name" placeholder="First Name" />
    <Input {store} type="text" name="last_name" placeholder="Last Name" />
    <Input {store} type="text" name="phone" placeholder="Phone Number" />
    <Input {store} type="email" name="email" placeholder="Email" />

    <h2>Billing Info</h2>

    <Input {store} type="text" name="ship_address" placeholder="Address" />
    <Input {store} type="text" name="ship_address2" placeholder="Address 2" />
    <Input {store} type="text" name="ship_city" placeholder="City" />
    <Select {store} name="ship_state">
      <option>State</option>

      {#each states as state}
        <option value={state}>{state}</option>
      {/each}
    </Select>
    <Input {store} type="text" name="ship_zip" placeholder="Zip Code" />

    <h2>Shipping Info</h2>

    <Input {store} type="text" name="bill_address" placeholder="Address" />
    <Input {store} type="text" name="bill_address2" placeholder="Address 2" />
    <Input {store} type="text" name="bill_city" placeholder="City" />
    <Select {store} name="bill_state">
      <option>State</option>

      {#each states as state}
        <option value={state}>{state}</option>
      {/each}
    </Select>
    <Input {store} type="text" name="bill_zip" placeholder="Zip Code" />

    <h2>Payment Info</h2>

    <Input {store} type="text" name="card" placeholder="Card Number" />
    <Input {store} type="text" name="month" placeholder="Month" />
    <Input {store} type="text" name="year" placeholder="Year" />
    <Input {store} type="text" name="cvv" placeholder="CVV" />
    <Input {store} type="text" name="card_zip" placeholder="Zip Code (optional)" />
  </Form>
</main>
Enter fullscreen mode Exit fullscreen mode

Form.svelte

<script lang="ts">
  import { createEventDispatcher } from "svelte";
  import { writable } from "svelte/store";

  import { local } from "../localStore";
  import type { JsonString } from "../types";

  export let name: string;

  const dispatch = createEventDispatcher();

  const store = name !== undefined ? local<JsonString>(name, {}) : writable({});

  const onSubmit = (e: Event) => dispatch("submit", { e, store });
</script>

<form on:submit={onSubmit} {...$$restProps}>
    <slot {store} />

    <br />

    <input type="submit" placeholder="Submit" />
</form>
Enter fullscreen mode Exit fullscreen mode

Input.svelte

<script lang="ts">
  import { createEventDispatcher } from "svelte";
  import type { Writable } from "svelte/store";

  import type { JsonString } from "../types";

  export let store: Writable<JsonString>;
  export let name: string;

  const dispatch = createEventDispatcher();

  let value: string;

  store.subscribe((v) => (value = v[name]));

  const onInput = (e: Event) => {
    store.update(v => {
      v[name] = value;

      return v;
    });

    dispatch("input", e);
  };
</script>

<input bind:value on:input={onInput} {name} {...$$restProps} />
Enter fullscreen mode Exit fullscreen mode

Select.svelte

<script lang="ts">
  import { createEventDispatcher } from "svelte";
  import type { Writable } from "svelte/store";

  import type { JsonString } from "../types";

  export let store: Writable<JsonString>;
  export let name: string;

  const dispatch = createEventDispatcher();

  let value: string;

  store.subscribe((v) => (value = v[name]));

  const onChange = (e: FocusEvent) => {
    store.update(v => {
      v[name] = value;

      return v;
    });

    dispatch("change", e);
  };
</script>

<select bind:value on:blur={onChange} {name} {...$$restProps}>
  <slot />
</select>
Enter fullscreen mode Exit fullscreen mode

There's a bit to unpack here; we have a lot of what appears to be meaningless complexity that manipulates such a simple form, but one simple form could easily turn into ten. On top of the modularity, state management is extremely important when handling user data to provide the best possible usability.

Here we have a basic Svelte setup. We have a generic Svelte index.js, and an App.svelte file that looks quite similar to the setup in step 1. In addition to that, we've added three more components named Form, Input, and Select; Input and Select are near identical with the respect to the elements they render. We have two more JS files as well: localStore and us_states, with the latter being a self-explanatory array, and the prior being a modified variation of a Svelte writable store that utilizes localStorage.

The Form component takes a name prop, and it returns a store (via slot variable) to be used by its children. This is to isolate the varying amount of forms in a project as each will need a different name for localStorage purposes. To bypass localStorage, just don't provide a name and it will create a writable store.

The Input and Select components take two managed props, and the remaining props that their respective elements take (thank you $$restProps); we also dispatch events for on:input and on:change respectively. The two props we want control of are: store, and name. The store prop is the store slot variable provided by the Form component. We steal name to use it in a custom input listener in order to preserve the value in our store. Finally, those custom input listeners just update the values of the form elements in our store, and dispatch events to allow further control for each independent form.

Time for the multi-step... step.

Step 3: Multi-Step Form

Continuing with what we've got:

types.ts

// ...

export type JsonBool = { [key: string]: boolean };
Enter fullscreen mode Exit fullscreen mode

App.svelte

<script lang="ts">
  // ...

  import Step from "./components/Step.svelte";

  // ...
</script>

<main>
    <Form {name} let:store let:multi>
    <Step name="Customer Info" {multi}>
      <!-- -->
    </Step>

    <Step name="Billing Info" {multi}>
      <!-- -->
    </Step>

    <Step name="Shipping Info" {multi}>
      <!-- -->
    </Step>

    <Step name="Payment Info" {multi}>
      <<!-- -->
    </Step>
  </Form>
</main>
Enter fullscreen mode Exit fullscreen mode

Form.svelte

<script lang="ts">
  import { createEventDispatcher, onMount } from "svelte";
  import { writable } from "svelte/store";
  import type { Writable } from "svelte/store";

  import { local } from "../localStore";
  import type { JsonString, JsonBool } from "../types";

  export let name: string;

  const dispatch = createEventDispatcher();

  const store = name !== undefined ? local<JsonString>(name, {}) : writable({});
  const multi: Writable<JsonBool> = writable({});

  let multi_loc: JsonBool = {};
  let current = 0;

  multi.subscribe(v => (multi_loc = v));

  const onSubmit = (e: Event) => dispatch("submit", { e, store });

  function prev() {
    if (Object.keys(multi_loc)[current - 1]) {
      multi.update(v => {
        v[Object.keys(multi_loc)[current]] = false;

        return v;
      });

      current -= 1;

      multi.update(v => {
        v[Object.keys(multi_loc)[current]] = true;

        return v;
      });
    }
  }

  function next() {
    if (Object.keys(multi_loc)[current + 1]) {
      multi.update(v => {
        v[Object.keys(multi_loc)[current]] = false;

        return v;
      });

      current += 1;

      multi.update(v => {
        v[Object.keys(multi_loc)[current]] = true;

        return v;
      });
    }
  }

  onMount(() => {
    multi.update(v => {
      v[Object.keys(multi_loc)[current]] = true;

      return v;
    });
  });
</script>

<div class="wrapper">
  <form {...$$restProps} class="form">
    <slot {store} {multi} />

    <div class="controls">
      {#if Object.keys(multi_loc)[current - 1]}
        <button on:click|preventDefault={prev}>Prev</button>
      {/if}

      {#if Object.keys(multi_loc)[current + 1]}
        <button on:click|preventDefault={next}>Next</button>
      {/if}

      {#if !Object.keys(multi_loc)[current + 1]}
        <input type="submit" placeholder="Submit" />
      {/if}
    </div>
  </form>
</div>
Enter fullscreen mode Exit fullscreen mode

Step.svelte

<script lang="ts">
  import type { Writable } from "svelte/store";

  import type { JsonBool } from "../types";

  export let name: string;
  export let multi: Writable<JsonBool>;

  multi.update(v => {
    v[name] = false;

    return v;
  });

  let visible = false;

  multi.subscribe(v => (visible = v[name]));
</script>

{#if visible}
  <div>
    <h2>{name}</h2>

    <slot />
  </div>
{/if}
Enter fullscreen mode Exit fullscreen mode

We have changed the Form component to manage the steps, like it manages its store. This means we have to pass another slot variable, multi, to the form's children, but this time the children consist of a new component. The multi variable is just a regular writable store with an object that will be a key with a boolean value to represent which step is the current. Once the Form component is mounted, we make the first step visisble.

The new component: Step. With this component we pass a name prop, and the slot variable multi provided by the Form component. In the component, we add the name to the multi variable with a default value of false to indicate the step is not visible. Now with our steps isolated, we can create fluid and more elegant forms to any project.

Conclusion

Not pretty by any means, but it serves its purpose. What we have now is a form component that can be single-step or multi-step with the use of the Step component. This allows us to have many forms in a project that have the potential to be multi-step.

Thank you for reading, and have a good one.

Top comments (0)