Contents

    Guides

    Managing State in a multi-step form

    Caleb Olojo

    Caleb is a Frontend developer and Technical writer. He enjoys creating JAMStack applications with Next.js.

    Published on

    March 17, 2022
    Managing State in a multi-step form

    Forms are an integral part of web applications. They’re needed to share the information that a user enters in the application to the web server, and there are various types of forms on the web.

    In this article, the reader will learn how to create a multi-step form in React and manage the state at every level of the form.

    But, before you read this article any further, it would help if you had a basic understanding of:

    • React.js and Next.js
    • React Hooks
    • Conditional rendering in React
    • React components and props

    Getting Started

    Web forms are known to cause sudden displeasure for people who visit a particular website/web application when they’re asked to fill in some details about themselves.

    Most of the forms are so long that it makes the user experience (UX) on such apps/sites unpleasant. This article will look at how we can build and manage the state in an interactive multi-step form in React.

    We’ll be using Next.js to bootstrap our app. You can use create-react-app if that’s what you’re comfortable with. We’re using Next.js because of its simplicity and a lot of its out-of-the-box features. You can read more about Next.js here.

    Let us start by installing the dependencies that we need in this project. We’d start by creating a Next.js app. The command below does that for us.

    npx create-next-app [name-of-your-app]

    We’ll use the styled-components library for styling our app and the react-awesome-reveal library for the form animations. We wouldn’t be covering much of the styling in this article.


    Let’s get the dependencies above by typing the command below into our terminal.

    npm install styled-components react-awesome-reveal

    Let us have a look at the file structure of the app below. We’ll focus on the important files that we need in this app, so it’ll be concise.

    |--pages
     |   |-- api
     |   |-- _app.js
     |   |-- index.js
     |--src
     |   |-- components
     |   |     |-- Forms.js
     |   |__
     |__

    Structure of our Next.js app

    In this section, we are going to see the different files that make up the architecture of this project, and their respective functions below.

    The pages directory is where all the routing of the app takes place. This is an out-of-the-box feature of Nextjs. It saves you the stress of hard-coding your independent routes.

    pages/api: the api directory enables you to have a backend for your Next.js app, inside the same codebase, instead of the common way of creating separate repositories for your REST or GraphQL APIs and deploying them on backend hosting platforms like Heroku, and so on.

    With the api directory, every file is treated as an API endpoint. Say, for example, we have a file called users.js in the api folder. That file becomes an API endpoint, which means an API call can be performed using the path to the file as the base URL.

    const getData = async() => {
    axios.get("/api/users")
       .then(response => response())
       .then(response => console.log(response.data))
       .catch(err => console.log(err)
    }
    

    pages/_app.js: is where all our components get attached to the DOM. If you look at the component structure, you’ll see that all the components are passed as pageProps to the Component props.

    It is like the index.js file when using Create-React-App. The only difference here is that you are not hooking your app to the DOM node, called “root”.

    React.render(document.getElementById("root"), <App/>

    index.js is the default route in the pages folder. That is where we’ll be doing most of the work on this project. When you run the command below, it starts up a development server and the contents of index.js are rendered on the web page.

    npm run dev

    Structure of the form

    In the previous sections, we went through the purpose of this article and the tools that we’ll be using. We also took a brief look at the project structure.

    In this section, we’ll be working closely with the structure and styling of the form components.
    Yes, we’ll be having multiple form components because we’re trying to create a multi-step form, such that, upon the invocation of an action, say a click of the button, a new set of input fields will enter into the viewport.

    Let’s take a look at the forms below:

      import React from "react";
      import propTypes from "prop-types";
      import { MainFormWrapper } from "./style/form.styled";
      import { Fade } from "react-awesome-reveal";
      
      const MainForm = () => {
        const [formStep, setFormStep] = React.useState(0);
        const [firstName, setFirstName] = React.useState("");
        const [lastName, setLastName] = React.useState("");
      
        return (
          <MainFormWrapper>
            <form className="mutli-form">
              {formStep === 0 && (
                <>
                  <Fade direction="up" cascade triggerOnce>
                    <div className="input-group">
                      <label htmlFor="fullname">First Name</label>
                      <input
                        name="firstname"
                        id="firstName"
                        type="text"
                        placeholder="First Name"
                        value={firstName}
                        onChange={(e) => setFirstName(e.target.value)}
                      />
                    </div>
                    <div className="input-group">
                      <label htmlFor="lastname">Last Name</label>
                      <input
                        name="lastname"
                        id="lastName"
                        type="text"
                        placeholder="Last Name"
                        value={lastName}
                        onChange={(e) => setLastName(e.target.value)}
                      />
                    </div>
                  </Fade>
                </>
              )}
              <button className="btn" name="button" onClick={nextForm}>
                Next Step
              </button>
            </form>
          </MainFormWrapper>
        );
      };
      export default MainForm;

    The main form holds the initial state of all the forms. We’re making use of the useState hook to achieve this. But then, we need a way to track the input changes that occur in the other forms, as we move to the next step of the form filling process.

    ... major imports
    
    const MainForm = () =< {
      const [formStep, setFormStep] = React.useState(0);
      ...
      const [email, setEmail] = React.useState("");
      const [address, setAddress] = React.useState("");
    
      return (
        <MainFormWrapper>
          <form className="mutli-form">
            ...
            {formStep === 1 && (
              <>
                <p className="interactive">
                  Hello <span>{firstName}</span>, <br />
                  please enter your email and home address
                </p>
                <SecondForm
                  emailAddress={email}
                  emailChange={(e) =< setEmail(e.target.value)}
                  address={address}
                  addressChange={(e) =< setAddress(e.target.value)}
                />
              </>
            )}
            <button className="btn" name="button" onClick={nextForm}>
              Next Step
            </button>
          </form>
        </MainFormWrapper>
      );
    };
    
    export default MainForm;

    The same process is repeated for the third form component.

    const MainForm = () => {
      const [formStep, setFormStep] = React.useState(0);
      ...
      const [phoneNumber, setPhoneNumber] = React.useState("");
    
      return (
        <MainFormWrapper>
          <form className="mutli-form">
            ...
            {formStep === 2 && (
              <ThirdForm
                phoneNumber={phoneNumber}
                phoneChange={(e) => setPhoneNumber(e.target.value)}
              />
            )}
            <button className="btn" name="button" onClick={nextForm}>
              Next Step
            </button>
          </form>
        </MainFormWrapper>
      );
    };
    
    export default MainForm;

    The two forms above will need to have a way to share the input data that the user types into their input fields with the main form so that such data isn’t lost during the process of transitioning to the next or previous step.

    Passing data with props through the form components

    Props (properties) are a very important feature of react. With props, data can be passed down and shared between different components at different levels.

    In the last section, we saw the bare structures of the form components and their functions. Here we’ll be looking at how we can access the data that is scoped to the second and third form, and pass them into the main form.

    export const SecondForm = ({
      emailAddress,
      emailChange,
      address,
      addressChange,
    }) => {
      return (
        <Fade direction="right" cascade triggerOnce>
          <div className="input-group">
            <label htmlFor="email address">Email address</label>
            <input
              name="email"
              id="email"
              type="email"
              placeholder="email address"
              value={emailAddress}
              onChange={emailChange}
            />
          </div>
          <div className="input-group">
            <label htmlFor="home address">Address</label>
            <input
              name="address"
              id="homeAddress"
              type="text"
              placeholder="Home Address"
              value={address}
              onChange={addressChange}
            />
          </div>
        </Fade>
      );
    };

    In the snippet above, you’ll see that we’re using the props: (emailAddress, emailChange, address, and addressChange) to share data and stateful logic among the components.

    We repeat the same process for the third form component.

    export const ThirdForm = ({ phoneNumber, phoneChange }) => {
      return (
        <Fade direction="right" cascade triggerOnce>
          <div className="input-group">
            <label htmlFor="email address">Phone Number</label>
            <input
              name="phoneNumber"
              id="phoneNumber"
              type="text"
              placeholder="Phone Number"
              value={phoneNumber}
              onChange={phoneChange}
            />
          </div>
        </Fade>
      );
    };

    The snippets above illustrate how we’re using props in react to pass data between components. You’d notice that there’s a prop validation going on below each form component.

    SecondForm.propTypes = {
      emailAddress: propTypes.string.isRequired,
      emailChange: propTypes.func.isRequired,
      address: propTypes.string.isRequired,
      addressChange: propTypes.func.isRequired,
    };

    The propType module helps us accomplish that. The snippet above, in literal terms, is just telling react that, ”hey, if a particular prop in this component is absent, throw an error that says it is required

    The prevForm and nextForm functions

    Now that we’ve seen the basics of all the components that we have in this article. It is time to implement the feature that actually makes the multi-step form come to life. We have to recall that we set the initial state value of the formStep to be 0. That being said, let’s take a look at the nextForm function below.

    const [formStep, setFormStep] = React.useState(0)
    
    const nextForm = (e) => {
      e.preventDefault();
      setFormStep((currentStep) => currentStep + 1);
    };

    The browser will always refresh when we click on the button that calls the nextForm function, that’s why it is necessary for us to prevent it from doing so by using e.preventDefault().

    We’ll repeat the same process for the prevForm function. But this time around, it will be without e.preventDefault and we’ll be reducing the state value.

    const prevForm = () => {
      setFormStep((currentStep) => currentStep - 1);
    };
    

    Improving the UX

    When the current state of the form is in the second step, we need a way to let people know the percentage, or how far they’ve filled the form. We can accomplish this by setting a maximum form step value. Say, for example 3, since the scope of this article has a three-step form.

    const MAX_FORM_STEP = 3;
    
    <p className="form-step">
      {formStep === MAX_FORM_STEP
        ? ""
        : `step ${formStep + 1} of ${MAX_FORM_STEP}`
      }
    </p>
    
    

    The snippet above will conditionally render the text in the <p> tag only when the formStep state variable is less than, and not equal to the max form step value.

    You’d notice that, 1 was appended to the formStep variable in the snippet above. This is because we don’t want to start counting from zero, as it would also affect the UX, since we’re used to counting our numerals from 1 instead of 0.

    On the same issue of prioritizing the user experience (UX), we need a way to allow people to go _—_ let’s say, a step backward in the form when they make a mistake. We can use conditional rendering to fix this too.

    {formStep > 0 ? <p onClick={prevForm}>go back</p> : ""}

    The snippet above will only render the paragraph element if the formStep is greater than zero.

    Now let’s say, the user is done filling the form. When they click on the button, the formStep is still incremented, which is something we do not want. So it’ll be better if we remove that counter and the button that is still visible also.

    {formStep === MAX_FORM_STEP ? (
        ""
      ) : (
      <button className="btn" name="button" onClick={nextForm}>
        Next Step
      </button>
    )}
    

    We replace the button with an empty string when the form step is the same as the maximum form step that we initially declared.

    The snippet above will continue to render the text “Next Step” in the button component at every level of the form, which doesn’t completely place the UX of the form at an optimal level. So, to make that change, we’ll have to update the snippet.

    {formStep === MAX_FORM_STEP ? (
      ""
     ) : (
      <button className="btn" name="button" onClick={nextForm}>
        {formStep === 2 ? "Finish" : "Next Step"}
      </button>
    )}

    You’ll notice that we added a new conditional statement in the button component. What the statement does, in a literal sense is; it tells the button to render the text “Finish” when the form step is at the final step, which is “2”. Remember how we started counting from “zero” in our initial state.

    Conclusion

    The GIF below shows the end product of what we’ve been building all along. As we move back and forth through the form, you'd notice that the values are still intact in the form.

    Thank you for reading this article. We hope it has helped you understand how to create and manage the state in an interactive multi-step form.

    Data-rich bug reports loved by everyone

    Get visual proof, steps to reproduce and technical logs with one click

    Make bug reporting 50% faster and 100% less painful

    Rating LogosStars
    4.6
    |
    Category leader

    Liked the article? Spread the word

    Put your knowledge to practice

    Try Bird on your next bug - you’ll love it

    “Game changer”

    Julie, Head of QA

    star-ratingstar-ratingstar-ratingstar-ratingstar-rating

    Overall rating: 4.7/5

    Try Bird later, from your desktop