Hire a web Developer and Designer to upgrade and boost your online presence with cutting edge Technologies

Saturday, December 28, 2024

Creating An Effective Multistep Form For Better User Experience

 Forms are already notoriously tough to customize and style — to the extent that we’re already starting to see new ideas for more flexible control. But what we don’t often discuss is designing good-form experiences beyond validation. That’s what Jima Victor discusses in this article, focusing specifically on creating multi-step forms that involve navigation between sections.

For a multistep form, planning involves structuring questions logically across steps, grouping similar questions, and minimizing the number of steps and the amount of required information for each step. Whatever makes each step focused and manageable is what should be aimed for.

In this tutorial, we will create a multistep form for a job application. Here are the details we are going to be requesting from the applicant at each step:

  • Personal Information
    Collects applicant’s name, email, and phone number.
  • Work Experience
    Collects the applicant’s most recent company, job title, and years of experience.
  • Skills & Qualifications
    The applicant lists their skills and selects their highest degree.
  • Review & Submit
    This step is not going to collect any information. Instead, it provides an opportunity for the applicant to go back and review the information entered in the previous steps of the form before submitting it.

You can think of structuring these questions as a digital way of getting to know somebody. You can’t meet someone for the first time and ask them about their work experience without first asking for their name.

Based on the steps we have above, this is what the body of our HTML with our form should look like. First, the main <form> element:

<form id="jobApplicationForm">
  <!-- Step 1: Personal Information -->
  <!-- Step 2: Work Experience -->
  <!-- Step 3: Skills & Qualifications -->
  <!-- Step 4: Review & Submit -->
</form>

Step 1 is for filling in personal information, like the applicant’s name, email address, and phone number:

<form id="jobApplicationForm">
  <!-- Step 1: Personal Information -->
  <fieldset class="step" id="step-1">
    <legend id="step1Label">Step 1: Personal Information</legend>
    <label for="name">Full Name</label>
    <input type="text" id="name" name="name" required />
    <label for="email">Email Address</label>
    <input type="email" id="email" name="email" required />
    <label for="phone">Phone Number</label>
    <input type="tel" id="phone" name="phone" required />
  </fieldset>

  <!-- Step 2: Work Experience -->
  <!-- Step 3: Skills & Qualifications -->
  <!-- Step 4: Review & Submit -->
</form>

Once the applicant completes the first step, we’ll navigate them to Step 2, focusing on their work experience so that we can collect information like their most recent company, job title, and years of experience. We’ll tack on a new <fieldset> with those inputs:

<form id="jobApplicationForm">
  <!-- Step 1: Personal Information -->

  <!-- Step 2: Work Experience -->
  <fieldset class="step" id="step-2" hidden>
    <legend id="step2Label">Step 2: Work Experience</legend>
    <label for="company">Most Recent Company</label>
    <input type="text" id="company" name="company" required />
    <label for="jobTitle">Job Title</label>
    <input type="text" id="jobTitle" name="jobTitle" required />
    <label for="yearsExperience">Years of Experience</label>
    <input
      type="number"
      id="yearsExperience"
      name="yearsExperience"
      min="0"
      required
    />
  </fieldset>

  <!-- Step 3: Skills & Qualifications -->
  <!-- Step 4: Review & Submit -->
</form>

Step 3 is all about the applicant listing their skills and qualifications for the job they’re applying for:

<form id="jobApplicationForm">
  <!-- Step 1: Personal Information -->
  <!-- Step 2: Work Experience -->

  <!-- Step 3: Skills & Qualifications -->
  <fieldset class="step" id="step-3" hidden>
    <legend id="step3Label">Step 3: Skills & Qualifications</legend>
    <label for="skills">Skill(s)</label>
    <textarea id="skills" name="skills" rows="4" required></textarea>
    <label for="highestDegree">Degree Obtained (Highest)</label>
    <select id="highestDegree" name="highestDegree" required>
      <option value="">Select Degree</option>
      <option value="highschool">High School Diploma</option>
      <option value="bachelor">Bachelor's Degree</option>
      <option value="master">Master's Degree</option>
      <option value="phd">Ph.D.</option>
    </select>
  </fieldset>
  <!-- Step 4: Review & Submit -->
  <fieldset class="step" id="step-4" hidden>
    <legend id="step4Label">Step 4: Review & Submit</legend>
    <p>Review your information before submitting the application.</p>
    <button type="submit">Submit Application</button>
  </fieldset>
</form>

And, finally, we’ll allow the applicant to review their information before submitting it:

<form id="jobApplicationForm">
  <!-- Step 1: Personal Information -->
  <!-- Step 2: Work Experience -->
  <!-- Step 3: Skills & Qualifications -->

  <!-- Step 4: Review & Submit -->
  <fieldset class="step" id="step-4" hidden>
    <legend id="step4Label">Step 4: Review & Submit</legend>
    <p>Review your information before submitting the application.</p>
    <button type="submit">Submit Application</button>
  </fieldset>
</form>
Notice: We’ve added a hidden attribute to every fieldset element but the first one. This ensures that the user sees only the first step. Once they are done with the first step, they can proceed to fill out their work experience on the second step by clicking a navigational button. We’ll add this button later on.

Adding Styles #

To keep things focused, we’re not going to be emphasizing the styles in this tutorial. What we’ll do to keep things simple is leverage the Simple.css style framework to get the form in good shape for the rest of the tutorial.

If you’re following along, we can include Simple’s styles in the document <head>:

<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />

And from there, go ahead and create a style.css file with the following styles that I’ve folded up.

<details>
  <summary>View CSS</summary>
  body {
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  main {
    padding: 0 30px;
  }
  h1 {
    font-size: 1.8rem;
    text-align: center;
  }
  .stepper {
    display: flex;
    justify-content: flex-end;
    padding-right: 10px;
  }
  form {
    box-shadow: 0px 0px 6px 2px rgba(0, 0, 0, 0.2);
    padding: 12px;
  }
  input,
  textarea,
  select {
    outline: none;
  }
  input:valid,
  textarea:valid,
  select:valid,
  input:focus:valid,
  textarea:focus:valid,
  select:focus:valid {
    border-color: green;
  }
  input:focus:invalid,
  textarea:focus:invalid,
  select:focus:invalid {
    border: 1px solid red;
  }
</details>

Form Navigation And Validation

An easy way to ruin the user experience for a multi-step form is to wait until the user gets to the last step in the form before letting them know of any error they made along the way. Each step of the form should be validated for errors before moving on to the next step, and descriptive error messages should be displayed to enable users to understand what is wrong and how to fix it.

Now, the only part of our form that is visible is the first step. To complete the form, users need to be able to navigate to the other steps. We are going to use several buttons to pull this off. The first step is going to have a Next button. The second and third steps are going to have both a Previous and a Next button, and the fourth step is going to have a Previous and a Submit button.

<form id="jobApplicationForm">
  <!-- Step 1: Personal Information -->
  <fieldset>
    <!-- ... -->
    <button type="button" class="next" onclick="nextStep()">Next</button>
  </fieldset>

  <!-- Step 2: Work Experience -->
  <fieldset>
    <!-- ... -->
    <button type="button" class="previous" onclick="previousStep()">Previous</button>
    <button type="button" class="next" onclick="nextStep()">Next</button>
  </fieldset>

  <!-- Step 3: Skills & Qualifications -->
  <fieldset>
    <!-- ... -->
    <button type="button" class="previous" onclick="previousStep()">Previous</button>
    <button type="button" class="next" onclick="nextStep()">Next</button>
  </fieldset>

  <!-- Step 4: Review & Submit -->
  <fieldset>
    <!-- ... -->
    <button type="button" class="previous" onclick="previousStep()">Previous</button>
    <button type="submit">Submit Application</button>
  </fieldset>
</form>

Notice: We’ve added onclick attributes to the Previous and Next buttons to link them to their respective JavaScript functions: previousStep() and nextStep().

The “Next” Button

The nextStep() function is linked to the Next button. Whenever the user clicks the Next button, the nextStep() function will first check to ensure that all the fields for whatever step the user is on have been filled out correctly before moving on to the next step. If the fields haven’t been filled correctly, it displays some error messages, letting the user know that they’ve done something wrong and informing them what to do to make the errors go away.

Before we go into the implementation of the nextStep function, there are certain variables we need to define because they will be needed in the function. First, we need the input fields from the DOM so we can run checks on them to make sure they are valid.

// Step 1 fields
const name = document.getElementById("name");
const email = document.getElementById("email");
const phone = document.getElementById("phone");

// Step 2 fields
const company = document.getElementById("company");
const jobTitle = document.getElementById("jobTitle");
const yearsExperience = document.getElementById("yearsExperience");

// Step 3 fields
const skills = document.getElementById("skills");
const highestDegree = document.getElementById("highestDegree");

Then, we’re going to need an array to store our error messages.

let errorMsgs = [];

Also, we would need an element in the DOM where we can insert those error messages after they’ve been generated. This element should be placed in the HTML just below the last fieldset closing tag:

<div id="errorMessages" style="color: rgb(253, 67, 67)"></div>

Add the above div to the JavaScript code using the following line:

const errorMessagesDiv = document.getElementById("errorMessages");

And finally, we need a variable to keep track of the current step.

let currentStep = 1;

Now that we have all our variables in place, here’s the implementation of the nextstep() function:

function nextStep() {
  errorMsgs = [];
  errorMessagesDiv.innerText = "";

  switch (currentStep) {
    case 1:
      addValidationErrors(name, email, phone);
      validateStep(errorMsgs);
      break;

    case 2:
      addValidationErrors(company, jobTitle, yearsExperience);
      validateStep(errorMsgs);
      break;

    case 3:
      addValidationErrors(skills, highestDegree);
      validateStep(errorMsgs);
      break;
  }
}

The moment the Next button is pressed, our code first checks which step the user is currently on, and based on this information, it validates the data for that specific step by calling the addValidationErrors() function. If there are errors, we display them. Then, the form calls the validateStep() function to verify that there are no errors before moving on to the next step. If there are errors, it prevents the user from going on to the next step.

Whenever the nextStep() function runs, the error messages are cleared first to avoid appending errors from a different step to existing errors or re-adding existing error messages when the addValidationErrors function runs. The addValidationErrors function is called for each step using the fields for that step as arguments.

Here’s how the addValidationErrors function is implemented:

function addValidationErrors(fieldOne, fieldTwo, fieldThree = undefined) {
  if (!fieldOne.checkValidity()) {
    const label = document.querySelector(`label[for="${fieldOne.id}"]`);
    errorMsgs.push(`Please Enter A Valid ${label.textContent}`);
  }

  if (!fieldTwo.checkValidity()) {
    const label = document.querySelector(`label[for="${fieldTwo.id}"]`);
    errorMsgs.push(`Please Enter A Valid ${label.textContent}`);
  }

  if (fieldThree && !fieldThree.checkValidity()) {
    const label = document.querySelector(`label[for="${fieldThree.id}"]`);
    errorMsgs.push(`Please Enter A Valid ${label.textContent}`);
  }

  if (errorMsgs.length > 0) {
    errorMessagesDiv.innerText = errorMsgs.join("\n");
  }
}

This is how the validateStep() function is defined:

function validateStep(errorMsgs) {
  if (errorMsgs.length === 0) {
    showStep(currentStep + 1);
  }
}

The validateStep() function checks for errors. If there are none, it proceeds to the next step with the help of the showStep() function.

function showStep(step) {
  steps.forEach((el, index) => {
    el.hidden = index + 1 !== step;
  });
  currentStep = step;
}

The showStep() function requires the four fieldsets in the DOM. Add the following line to the top of the JavaScript code to make the fieldsets available:

const steps = document.querySelectorAll(".step");

What the showStep() function does is to go through all the fieldsets in our form and hide whatever fieldset is not equal to the one we’re navigating to. Then, it updates the currentStep variable to be equal to the step we’re navigating to.

The “Previous” Button #

The previousStep() function is linked to the Previous button. Whenever the previous button is clicked, similarly to the nextStep function, the error messages are also cleared from the page, and navigation is also handled by the showStep function.

function previousStep() {
  errorMessagesDiv.innerText = "";
  showStep(currentStep - 1);
}

Whenever the showStep() function is called with “currentStep - 1” as an argument (as in this case), we go back to the previous step, while moving to the next step happens by calling the showStep() function with “currentStep + 1” as an argument (as in the case of the validateStep() function).

Improving User Experience With Visual Cues #

One other way of improving the user experience for a multi-step form, is by integrating visual cues, things that will give users feedback on the process they are on. These things can include a progress indicator or a stepper to help the user know the exact step they are on.

Integrating A Stepper #

To integrate a stepper into our form (sort of like this one from Material Design), the first thing we need to do is add it to the HTML just below the opening <form> tag.

<form id="jobApplicationForm">
  <div class="stepper">
    <span><span class="currentStep">1</span>/4</span>
  </div>
  <!-- ... -->
</form>

Next, we need to query the part of the stepper that will represent the current step. This is the span tag with the class name of currentStep.

const currentStepDiv = document.querySelector(".currentStep");

Now, we need to update the stepper value whenever the previous or next buttons are clicked. To do this, we need to update the showStep() function by appending the following line to it:

currentStepDiv.innerText = currentStep;

This line is added to the showStep() function because the showStep() function is responsible for navigating between steps and updating the currentStep variable. So, whenever the currentStep variable is updated, the currentStepDiv should also be updated to reflect that change.

Storing And Retrieving User Data #

One major way we can improve the form’s user experience is by storing user data in the browser. Multistep forms are usually long and require users to enter a lot of information about themselves. Imagine a user filling out 95% of a form, then accidentally hitting the F5 button on their keyboard and losing all their progress. That would be a really bad experience for the user.

Using localStorage, we can store user information as soon as it is entered and retrieve it as soon as the DOM content is loaded, so users can always continue filling out their forms from wherever they left off. To add this feature to our forms, we can begin by saving the user’s information as soon as it is typed. This can be achieved using the input event.

Before adding the input event listener, get the form element from the DOM:

const form = document.getElementById("jobApplicationForm");

Now we can add the input event listener:

// Save data on each input event
form.addEventListener("input", () => {
  const formData = {
    name: document.getElementById("name").value,
    email: document.getElementById("email").value,
    phone: document.getElementById("phone").value,
    company: document.getElementById("company").value,
    jobTitle: document.getElementById("jobTitle").value,
    yearsExperience: document.getElementById("yearsExperience").value,
    skills: document.getElementById("skills").value,
    highestDegree: document.getElementById("highestDegree").value,
  };
  localStorage.setItem("formData", JSON.stringify(formData));
});

Next, we need to add some code to help us retrieve the user data once the DOM content is loaded.

window.addEventListener("DOMContentLoaded", () => {
  const savedData = JSON.parse(localStorage.getItem("formData"));
  if (savedData) {
    document.getElementById("name").value = savedData.name || "";
    document.getElementById("email").value = savedData.email || "";
    document.getElementById("phone").value = savedData.phone || "";
    document.getElementById("company").value = savedData.company || "";
    document.getElementById("jobTitle").value = savedData.jobTitle || "";
    document.getElementById("yearsExperience").value = savedData.yearsExperience || "";
    document.getElementById("skills").value = savedData.skills || "";
    document.getElementById("highestDegree").value = savedData.highestDegree || "";
  }
});

Lastly, it is good practice to remove data from localStorage as soon as it is no longer needed:

// Clear data on form submit
form.addEventListener('submit', () => {
  // Clear localStorage once the form is submitted
  localStorage.removeItem('formData');
}); 

Adding The Current Step Value To localStorage

If the user accidentally closes their browser, they should be able to return to wherever they left off. This means that the current step value also has to be saved in localStorage.

To save this value, append the following line to the showStep() function:

localStorage.setItem("storedStep", currentStep);

Now we can retrieve the current step value and return users to wherever they left off whenever the DOM content loads. Add the following code to the DOMContentLoaded handler to do so:

const storedStep = localStorage.getItem("storedStep");

if (storedStep) {
    const storedStepInt = parseInt(storedStep);
    steps.forEach((el, index) => {
      el.hidden = index + 1 !== storedStepInt;
    });
    currentStep = storedStepInt;
    currentStepDiv.innerText = currentStep;
  }

Also, do not forget to clear the current step value from localStorage when the form is submitted.

localStorage.removeItem("storedStep");

The above line should be added to the submit handler.

Wrapping Up #

Creating multi-step forms can help improve user experience for complex data entry. By carefully planning out steps, implementing form validation at each step, and temporarily storing user data in the browser, you make it easier for users to complete long forms.

For the full implementation of this multi-step form, you can access the complete code on GitHub.

The Hype Around Signals

 From KnockoutJS to modern UI libraries like SolidJS, Vue.js, and Svelte, signals revolutionized how we think about reactivity in UIs. Here’s a deep dive into their history and impact

The groundwork for what we call today “signals” dates as early as the 1970s. Based on this work, they became popular with different fields of computer science, defining them more specifically around the 90s and the early 2000s.

In Web Development, they first made a run for it with KnockoutJS, and shortly after, signals took a backseat in (most of) our brains. Some years ago, multiple similar implementations came to be.

With different names and implementation details, those approaches are similar enough to be wrapped in a category we know today as Fine-Grained Reactivity, even if they have different levels of “fine” x “coarse” updates — we’ll get more into what this means soon enough.

To summarize the history: Even being an older technology, signals started a revolution in how we thought about interactivity and data in our UIs at the time. And since then, every UI library (SolidJS, Marko, Vue.js, Qwik, Angular, Svelte, Wiz, Preact, etc) has adopted some kind of implementation of them (except for React).

Typically, a signal is composed of an accessor (getter) and a setter. The setter establishes an update to the value held by the signal and triggers all dependent effects. While an accessor pulls the value from the source and is run by effects every time a change happens upstream.

const [signal, setSignal] = createSignal("initial value");

setSignal("new value");

console.log(signal()); // "new value"

In order to understand the reason for that, we need to dig a little deeper into what API Architectures and Fine-Grained Reactivity actually mean.

API Architectures #

There are two basic ways of defining systems based on how they handle their data. Each of these approaches has its pros and cons.

  • Pull: The consumer pings the source for updates.
  • Push: The source sends the updates as soon as they are available.

Pull systems need to handle polling or some other way of maintaining their data up-to-date. They also need to guarantee that all consumers of the data get torn down and recreated once new data arrives to avoid state tearing.

State Tearing occurs when different parts of the same UI are at different stages of the state. For example, when your header shows 8 posts available, but the list has 10.

Push systems don’t need to worry about maintaining their data up-to-date. Nevertheless, the source is unaware of whether the consumer is ready to receive the updates. This can cause backpressure. A data source may send too many updates in a shorter amount of time than the consumer is capable of handling. If the update flux is too intense for the receiver, it can cause loss of data packages (leading to state tearing once again) and, in more serious cases, even crash the client.

In pull systems, the accepted tradeoff is that data is unaware of where it’s being used; this causes the receiving end to create precautions around maintaining all their components up-to-date. That’s how systems like React work with their teardown/re-render mechanism around updates and reconciliation.

In push systems, the accepted tradeoff is that the receiving end needs to be able to deal with the update stream in a way that won’t cause it to crash while maintaining all consuming nodes in a synchronized state. In web development, RxJS is the most popular example of a push system.

The attentive reader may have noticed the tradeoffs on each system are at the opposite ends of the spectrum: while pull systems are good at scheduling the updates efficiently, in push architectures, the data knows where it’s being used — allows for more granular control. That’s what makes a great opportunity for a hybrid model.

Push-Pull Architectures

In Push-Pull systems, the state has a list of subscribers, which can then trigger for re-fetching data once there is an update. The way it differs from traditional push is that the update itself isn’t sent to the subscribers — just a notification that they’re now stale.

Once the subscriber is aware its current state has become stale, it will then fetch the new data at a proper time, avoiding any kind of backpressure and behaving similarly to the pull mechanism. The difference is that this only happens when the subscriber is certain there is new data to be fetched.

We call these data signals, and the way those subscribers are triggered to update are called effects. Not to confuse with useEffect, which is a similar name for a completely different thing.

Fine-Grained Reactivity

Once we establish the two-way interaction between the data source and data consumer, we will have a reactive system.

A reactive system only exists when data can notify the consumer it has changed, and the consumer can apply those changes.

Now, to make it fine-grained there are two fundamental requirements that need to be met:

  1. Efficiency: The system only executes the minimum amount of computations necessary.
  2. Glitch-Free: No intermediary states are shown in the process of updating a state.

Efficiency In UIs #

To really understand how signals can achieve high levels of efficiency, one needs to understand what it means to have an accessor. In broad strokes, they behave as getter functions. Having an accessor means the value does not exist within the boundaries of our component — what our templates receive is a getter for that value, and every time their effects run, they will bring an up-to-date new value. This is why signals are functions and not simple variables. For example, in Solid:

import { createSignal } from 'solid-js'

function ReactiveComponent() {
  const [signal, setSignal] = createSignal()
  
  return (
    <h1>Hello, {signal()}</h1>
  )
}

The part that is relevant to performance (and efficiency) in the snippet above is that considering signal() is a getter, it does not need to re-run the whole ReactiveComponent() function to update the rendered artifact; only the signal is re-run, guaranteeing no extra computation will run.

Glitch-Free UIs 

Non-reactive systems avoid intermediary states by having a teardown/re-render mechanism. They toss away the artifacts with possibly stale data and recreate everything from scratch. That works well and consistently but at the expense of efficiency.

In order to understand how reactive systems handle this problem, we need to talk about the Diamond Challenge. This is a quick problem to describe but a tough one to solve.

Pay attention to the E node. It depends on D and B, but only D depends on C.

If your reactive system is too eager to update, it can receive the update from B while D is still stale. That will cause E to show an intermediary state that should not exist.

It’s easy and intuitive to have A trigger its children for updates as soon as new data arrives and let it cascade down. But if this happens, E receives the data from B while D is stale. If B is able to trigger an update from E, E will show an intermediate state.

Each implementation adopts different update strategies to solve this challenge. They can be grouped into two categories:

  1. Lazy Signals
    Where a scheduler defines the order within which the updates will occur. (A, then B and C, then D, and finally E).
  2. Eager Signals
    When signals are aware if their parents are stale, checking, or clean. In this approach, when E receives the update from B, it will trigger a check/update on D, which will climb up until it can ensure to be back in a clean state, allowing E to finally update.

Back To Our UIs

After this dive into what fine-grained reactivity means, it’s time to take a step back and look at our websites and apps. Let’s analyze what it means to our daily work.

DX And UX

When the code we wrote is easier to reason about, we can then focus on the things that really matter: the features we deliver to our users. Naturally, tools that require less work to operate will deliver less maintenance and overhead for the craftsperson.

A system that is glitch-free and efficient by default will get out of the developer’s way when it’s time to build with it. It will also enforce a higher connection to the platform via a thinner abstraction layer.

When it comes to Developer Experience, there is also something to be said about known territory. People are more productive within the mental models and paradigms they are used to. Naturally, solutions that have been around for longer and have solved a larger quantity of challenges are easier to work with, but that is at odds with innovation. It was a cognitive exercise when JSX came around and replaced imperative DOM updates with jQuery. In the same way, a new paradigm to handle rendering will cause a similar discomfort until it becomes common.

Going Deeper

We will talk further about this in the next article, where we’re looking more closely into different implementations of signals (lazy, eager, hybrid), scheduled updates, interacting with the DOM, and debugging your own code!