DEVELOPMENT by Pedro Resende

How to create a multistep form using React.js

Next.js, TypeScript and Tailwind CSS

Post by Pedro Resende on the 6 of February of 2022 at 13:00

Demo

This week at work, I had to develop a multistep, or multistage, web form using React.js.

Therefore I decided to create a simple tutorial to help out people who are trying to do the same, but this time I'll be using TypeScript and Tailwind CSS.

First, let's start by creating a new next.js project, by using the next.js create-app command.


$ npx create-next-app my-multistage-form && cd my-multistage-form

after the project is created, you have the following tree

Project tree

let's start by installing Tailwind CSS, by running the following commands

$ npm install -D tailwindcss
$ npx tailwindcss init

Add the paths to all of your template files in your tailwind.config.js file.

module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Add the @tailwind directives for each of Tailwind’s layers to your ./styles/globals.css file.

@tailwind base;
@tailwind components;
@tailwind utilities;

Now, we need to add the @types, since we are using TypeScript.

$ npm install -D @types/node

Run your build process with npm run dev.

$ npm run dev

Now that we have Tailwind CSS, TypeScript and Next.js installed, let's create our form component.

Let's create start by creating a new folder, on the root of the project called components, this folder will be used to store all of our project components.

Now create a new file called Form.tsx in this folder.

Project tree

Now, let's create our form component, let's add the component skeleton.

const Form = () => {
  return <></>
}

export default Form

Now let's add the form structure.

const Form = () => {
  return (
    <div className="mx-auto w-1/2 mt-4">
      <form></form>
    </div>
  )
}

we need a function to handle the form submission, let's add it.

const Form = () => {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
  }

  return (
    <div className="mx-auto w-1/2 mt-4">
      <form onSubmit={handleSubmit}></form>
    </div>
  )
}

Now for the multistage, let's add two components, one for each stage.

const Step1 = () => <></>

const Step2 = () => <></>

Now, let's add a step controller, this will be used to control the flow of the form.

import { useState } from "react"

const Step1 = () => <></>

const Step2 = () => <></>

const Form = () => {
  const [step, setStep] = useState<number>(1)

  const handleSubmit = (event: { preventDefault: () => void }) => {
    event.preventDefault()
  }

  return (
    <div className="mx-auto w-1/2 mt-4">
      <form onSubmit={handleSubmit}></form>
    </div>
  )
}

export default Form

Now, let's add the Steps components to the main component, I've also passed the setStep function to the Step1 and Step2 components to allow us to control the step we're in.

import { useState } from "react"

const Step1 = ({ setStep }: { setStep: (params: number) => void }) => <></>

const Step2 = ({ setStep }: { setStep: (params: number) => void }) => <></>

const Form = () => {
  const [step, setStep] = useState<number>(1)

  const handleSubmit = (event: { preventDefault: () => void }) => {
    event.preventDefault()
  }

  return (
    <div className="mx-auto w-1/2 mt-4">
      <form onSubmit={handleSubmit}>
        {
          {
            1: <Step1 setStep={setStep} />,
            2: <Step2 setStep={setStep} />,
          }[step]
        }
      </form>
    </div>
  )
}

export default Form

Ok, now let's add the input fields and the submit button to Step1 and Step2

const Step1 = ({ setStep }: { setStep: (params: number) => void }) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">First Name</span>
      <input type="text" className="border w-full" name="firstName" />
    </label>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Last Name</span>
      <input type="text" className="border w-full" name="lastName" />
    </label>
    <div className="flex justify-end">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(2)}
      >
        Continue
      </button>
    </div>
  </>
)

const Step2 = ({ setStep }: { setStep: (params: number) => void }) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Message</span>
      <textarea name="message" className="border w-full" />
    </label>
    <div className="flex w-full justify-between">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(1)}
      >
        Back
      </button>
      <button
        type="submit"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
      >
        Submit
      </button>
    </div>
  </>
)

Now, let's add a useState to control the field values.

import { useState } from "react"

type InitialState = {
  firstName: string
  lastName: string
  message: string
}

const Step1 = ({
  setStep,
  values,
}: {
  setStep: (params: number) => void
  values: InitialState
}) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">First Name</span>
      <input
        type="text"
        className="border w-full"
        name="firstName"
        value={values.firstName}
      />
    </label>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Last Name</span>
      <input
        type="text"
        className="border w-full"
        name="lastName"
        value={values.lastName}
      />
    </label>
    <div className="flex justify-end">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(2)}
      >
        Continue
      </button>
    </div>
  </>
)

const Step2 = ({
  setStep,
  values,
}: {
  setStep: (params: number) => void
  values: InitialState
}) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Message</span>
      <textarea
        name="message"
        className="border w-full"
        value={values.message}
      />
    </label>
    <div className="flex w-full justify-between">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(1)}
      >
        Back
      </button>
      <button
        type="submit"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
      >
        Submit
      </button>
    </div>
  </>
)

const initialState = {
  firstName: "",
  lastName: "",
  message: "",
}

const Form = () => {
  const [step, setStep] = useState<number>(1)
  const [values, setValues] = useState<InitialState>(initialState)

  const handleSubmit = (event: { preventDefault: () => void }) => {
    event.preventDefault()
  }

  return (
    <div className="mx-auto w-1/2 mt-4">
      <form onSubmit={handleSubmit}>
        {
          {
            1: <Step1 setStep={setStep} values={values} />,
            2: <Step2 setStep={setStep} values={values} />,
          }[step]
        }
      </form>
    </div>
  )
}

export default Form

Finally, let's add the changeHandler to set the values to the state and the submitHandler to send the data to the server.

import { useState } from "react"

const Step1 = ({
  setStep,
  values,
  changeValue,
}: {
  setStep: (params: number) => void
  values: InitialState
  changeValue: (params: React.ChangeEvent<HTMLInputElement>) => void
}) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">First Name</span>
      <input
        type="text"
        className="border w-full"
        name="firstName"
        value={values.firstName}
        onChange={changeValue}
      />
    </label>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Last Name</span>
      <input
        type="text"
        className="border w-full"
        name="lastName"
        value={values.lastName}
        onChange={changeValue}
      />
    </label>
    <div className="flex justify-end">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(2)}
      >
        Continue
      </button>
    </div>
  </>
)

const Step2 = ({
  setStep,
  values,
  changeValue,
}: {
  setStep: (params: number) => void
  values: InitialState
  changeValue: (params: React.ChangeEvent<HTMLTextAreaElement>) => void
}) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Message</span>
      <textarea
        name="message"
        className="border w-full"
        value={values.message}
        onChange={changeValue}
      />
    </label>
    <div className="flex w-full justify-between">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(1)}
      >
        Back
      </button>
      <button
        type="submit"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
      >
        Submit
      </button>
    </div>
  </>
)

type InitialState = {
  firstName: string
  lastName: string
  message: string
}

const initialState = {
  firstName: "",
  lastName: "",
  message: "",
}

const Form = () => {
  const [step, setStep] = useState<number>(1)
  const [values, setValues] = useState<InitialState>(initialState)

  const changeValue = (
    event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { value, name } = event.target
    setValues({ ...values, [name]: value })
  }

  const handleSubmit = (event: { preventDefault: () => void }) => {
    event.preventDefault()
    console.log(values)
  }

  return (
    <div className="mx-auto w-1/2 mt-4">
      <form onSubmit={handleSubmit}>
        {
          {
            1: (
              <Step1
                setStep={setStep}
                values={values}
                changeValue={changeValue}
              />
            ),
            2: (
              <Step2
                setStep={setStep}
                values={values}
                changeValue={changeValue}
              />
            ),
          }[step]
        }
      </form>
    </div>
  )
}

export default Form

And that's it, now the cherry on top of the cake, let's add the nice radio buttons to the form.

const Step = ({
  step,
  setStep,
}: {
  step: number
  setStep: (params: number) => void
}) => (
  <div className="flex mt-14 justify-center w-full">
    {[1, 2].map((currentStep: number) => (
      <label
        key={currentStep}
        className={`ring-offset-2 ring-2 rounded-full w-2 h-2 mr-4 hover:cursor-pointer hover:ring-4 ${
          step === currentStep ? "bg-blue-900" : "bg-blue-100"
        }`}
        title={`step ${currentStep}`}
        onClick={() => setStep(currentStep)}
      />
    ))}
  </div>
)

and now we can add the radio buttons to the form.

const Form = () => {
  const [step, setStep] = useState<number>(1)
  const [values, setValues] = useState<InitialState>(initialState)

  const changeValue = (
    event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { value, name } = event.target
    setValues({ ...values, [name]: value })
  }

  const handleSubmit = (event: { preventDefault: () => void }) => {
    event.preventDefault()
    console.log(values)
  }

  return (
    <>
      <Step step={step} setStep={setStep} />
      <div className="mx-auto w-1/2 mt-4">
        <form onSubmit={handleSubmit}>
          {
            {
              1: (
                <Step1
                  setStep={setStep}
                  values={values}
                  changeValue={changeValue}
                />
              ),
              2: (
                <Step2
                  setStep={setStep}
                  values={values}
                  changeValue={changeValue}
                />
              ),
            }[step]
          }
        </form>
      </div>
    </>
  )
}

here is the complete solution

import { useState } from "react"

const Step = ({
  step,
  setStep,
}: {
  step: number
  setStep: (params: number) => void
}) => (
  <div className="flex mt-14 justify-center w-full">
    {[1, 2].map((currentStep: number) => (
      <label
        key={currentStep}
        className={`ring-offset-2 ring-2 rounded-full w-2 h-2 mr-4 hover:cursor-pointer hover:ring-4 ${
          step === currentStep ? "bg-blue-900" : "bg-blue-100"
        }`}
        title={`step ${currentStep}`}
        onClick={() => setStep(currentStep)}
      />
    ))}
  </div>
)

const Step1 = ({
  setStep,
  values,
  changeValue,
}: {
  setStep: (params: number) => void
  values: InitialState
  changeValue: (params: React.ChangeEvent<HTMLInputElement>) => void
}) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">First Name</span>
      <input
        type="text"
        className="border w-full"
        name="firstName"
        value={values.firstName}
        onChange={changeValue}
      />
    </label>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Last Name</span>
      <input
        type="text"
        className="border w-full"
        name="lastName"
        value={values.lastName}
        onChange={changeValue}
      />
    </label>
    <div className="flex justify-end">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(2)}
      >
        Continue
      </button>
    </div>
  </>
)

const Step2 = ({
  setStep,
  values,
  changeValue,
}: {
  setStep: (params: number) => void
  values: InitialState
  changeValue: (params: React.ChangeEvent<HTMLTextAreaElement>) => void
}) => (
  <>
    <label className="mb-2 flex">
      <span className="mr-2 flex-none">Message</span>
      <textarea
        name="message"
        className="border w-full"
        onChange={changeValue}
        value={values.message}
      />
    </label>
    <div className="flex w-full justify-between">
      <button
        type="button"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
        onClick={() => setStep(1)}
      >
        Back
      </button>
      <button
        type="submit"
        className="bg-blue-500 text-white px-4 py-2 rounded-full mt-4"
      >
        Submit
      </button>
    </div>
  </>
)

type InitialState = {
  firstName: string
  lastName: string
  message: string
}

const initialState = {
  firstName: "",
  lastName: "",
  message: "",
}

const Form = () => {
  const [step, setStep] = useState<number>(1)
  const [values, setValues] = useState<InitialState>(initialState)

  const changeValue = (
    event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { value, name } = event.target
    setValues({ ...values, [name]: value })
  }

  const handleSubmit = (event: { preventDefault: () => void }) => {
    event.preventDefault()
    console.log(values)
  }

  return (
    <>
      <Step step={step} setStep={setStep} />
      <div className="mx-auto w-1/2 mt-4">
        <form onSubmit={handleSubmit}>
          {
            {
              1: (
                <Step1
                  setStep={setStep}
                  values={values}
                  changeValue={changeValue}
                />
              ),
              2: (
                <Step2
                  setStep={setStep}
                  values={values}
                  changeValue={changeValue}
                />
              ),
            }[step]
          }
        </form>
      </div>
    </>
  )
}

export default Form

finally, to have it on a given page, we only need to add the form to the page.

let's open the index.tsx file and add the form to the page.

import Head from "next/head"
import Image from "next/image"
import styles from "../styles/Home.module.css"
import Form from "../components/Form"

export default function Home() {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <Form />
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{" "}
          <span className={styles.logo}>
            <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
          </span>
        </a>
      </footer>
    </div>
  )
}

And that's all. Please let me know what you about this tutorial, would you change anything or add something?

Pro tip: You should extract each component Step, Step1, Step2 to a separate files.

Code available at Github