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
Tags: devdevelopmentreact.jsformtypescripttutorialnext.js
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
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.
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