Georgey V B I'm a self-taught web developer from India. I enjoy learning about new web technologies and working on projects. I hope that my work will assist other developers who are experiencing the same difficulties that I am.

How to build a multi-step form with only the useState Hook

7 min read 2164

How To Build A Multi Step Form With Only The UseState Hook

Forms are an essential part of most applications. There are a lot of packages available on the different packages for building forms. In this article we will be building a multi-step form using the useState Hook without the use of any third-party packages while achieving the same functionality.

The main priority of the tutorial is to analyze the logic of a multi-step form. We’ll be skipping most of the styling part, so if you get stuck anywhere, check out this repository.

Here’s what we’ll be covering in this tutorial, so feel free to jump ahead to any section you want:

Initializing the starting files

Let’s install a new Next.js project by entering this command:

npx create-next-app mulitstep-form

If you would prefer, a React project would work perfectly fine as well. To install a new React project, enter the following:

npx create-react-app multistep-form 

Styling is optional, but if you would like to follow along, we’ll be using Mantine CSS for this project.

Install Mantine CSS

npm install @mantine/core @mantine/next @mantine/hooks

Follow the setup guide for Next.js here.

Next, let’s build some components for each step in our multi-step form.

First, we’ll build the Form component, which will handle most of our form logic. Go ahead and make a new file at ./components/Form.js.

We made a custom demo for .
No really. Click here to check it out.

import { Box, Button, Title } from '@mantine/core';

function Form() {
  return (
    <Box
      sx={boxStyle}
    >
      <Title
        sx={{
          textAlign: 'center',
        }}
        order={2}
      >
        Hey there!
      </Title>
        {/* Steps */}
      <Button>Submit</Button>
    </Box>
  );
}
export default Form;

(Note: Here, the term “Steps” simply means the different stages of the multi-step form)

Next, let’s make the first step of our form — a simple introductory message.

Create a new file in ./components/First/index.js:

import { Box, Text, TextInput } from '@mantine/core';
function First() {
  return (
    <Box
      sx={boxStyle}
    >
      <Text>To start with, whats your beautiful name?</Text>
      <Box
        sx={{
          margin: '1rem 0',
        }}
      >
        <TextInput
          placeholder="John Doe"
          required
        />
      </Box>
    </Box>
  );
}
export default First;

This is what your output should look like now:

State Hook Simple Introductory Message

Using the useState Hook to move between components

Next, we’ll use the useState Hook to move between the different steps.

We’ve only made a single step until now, but feel free to add in your own steps. Below is what the flow should look like in this tutorial.

UseState Hook Tutorial Example Screens

Now with the components ready, let’s add the logic to switch between components.

Initialize a new useState Hook, with its initial value as 0.

const [page, setPage] = useState(0);

Create another function that will conditionally render a different component base on the page value:

import First from './First';
import SecondStep from './Second';
import ThirdStep from './Third';

export default function Form () {
  const conditionalComponent = () => {
    switch (page) {
      case 0:
        return <First />;
      case 1:
        return <SecondStep />;
       case 2:
         return <ThirdStep />;
       default:
         return <First />;
    }
  }; 

return (
    <>
      <Box>
        {conditionalComponent()}
      </Box>
    </>
  )
}

Next up, let’s create a button which, on onClick, increments the value of page by 1:

//...imports
import { Button } from "@mantine/core"

export default function Form () {
  // stuff

  function handleSubmit () {
    //...stuff
  }

  return (
    <>
      {conditionalComponent()}
      <Button onClick={handleSubmit}>
        { page === 0 || page === 1 ? "Next" : "Submit" }
      </Button>
    </>
  )
}

In the meantime, in the handleSubmit function, we will increment the page. Further ahead in this article, we will be adding validations and use the fetch API to send data over to a Supabase instance.

function handleSubmit () {
  setPage(page + 1);
}

Similarly, we need a button and a function to go back to the previous page. The back button won’t be displayed if the user is on the first page, hence:

{
  page > 0 && <Button onClick={() => setPage(page - 1)}>Back</Button>
}

Keeping track of form data

To keep track of the user input , we’ll create another useState Hook.

const [formData, setFormData] = useState({
  name: '',
  email: '',
  employment_status: null
});

Next, we will pass both formData and setFormData as props to each of our conditional components.

const conditionalComponent = () => {
   switch (page) {
    case 0:
       return <First formData={formData} setFormData={setFormData} />;
     case 1:
      return <SecondStep formData={formData} setFormData={setFormData} />;
    case 2:
      return <ThirdStep formData={formData} setFormData={setFormData} />;
    default:
      return <First formData={formData} setFormData={setFormData} />;
  }
};

To get the form data from our input fields, we will make use of the value and onChange property inside the Input fields.

import { Box, Text, TextInput } from '@mantine/core';

function First({ formData, setFormData }) {
  return (
    <Box
      sx={boxStyle}
    >
      <Text>To start with, whats your beautiful name?</Text>
      <Box>
        <TextInput
          onChange={(e) => {
            setFormData({
              ...formData,
              name: e.target.value,
            });
          }}
          value={formData.name}
          placeholder="John Doe"
          required
        />
      </Box>
    </Box>
  );
}

export default First;

Two things are happening here:

  • The value property contains the current value of the respective Input field
  • The onChange property is called every single time the user tries to change the Input field

The select component is made a controlled input as follows:

<Select
    data={[
    { value: 'Student', label: 'Student' },
    { value: 'Employed', label: 'Employed' },
    { value: 'Business', label: 'Business' },
   ]}
    onChange={(e) => {
    console.log(e);
    setFormData({ ...formData, employment_status: e });
   }}
   value={formData.employment_status}
   sx={selectInputStyle}
   label="Your emplyment status"
   placeholder="Choose one"
/>

Here, instead of e.target.value, we are simply passing the e object as whole. Make sure you don’t pass anything else other than a string in the data prop inside the Select component.

In our last step, we will simply display the form data for the user to cross-check and verify, and — if required — give the user the option to go back and change the input previously entered.

Since we don’t have any input fields in the last component, just pass the formData and display them accordingly:

import { Box, Text } from '@mantine/core';
import Detail from '../Detail';

function ThirdStep({ formData }) {
  const boxStyle = {
    width: '70%',
    margin: '1rem auto',
    textAlign: 'center',
    padding: '1rem 0',
  };

  return (
    <Box sx={{ ...boxStyle, textAlign: 'left' }}>
      <Detail title="Name" data={formData.name} />
      <Detail title="Email" data={formData.email} />
      <Detail title="Employment status" data={formData.employment_status} />
    </Box>
  );
}
export default ThirdStep;

The Detail component looks like this:

import { Box, Text } from '@mantine/core';

function Detail({ title, data }) {
  return (
    <Box
      sx={{
        margin: '10px 0',
      }}
    >
      <Text weight={300}>{title}</Text>
      <Text>{data}</Text>
    </Box>
  );
}

export default Detail;

With that done, your multi-step form can now seamlessly keep track of the form data.

Adding form validation

Next up, we will add form validation, to accept valid data from the user.

In the handleSubmit function, we will make create an if...else flow to check the input fields on each page and subsequently move the user to the next page.

function handleSubmit () {
  if (page === 0) {
    // do form validation
  } else if (page === 1) {
    // do form validation again
  } else if (page === 2) {
    // set page === 0 , and clear fields
  } else setPage(page + 1);
} 

In the first if statement:

if (page === 0) {
  if (formData.name === '' || formData.name.length <= 1) {
    return alert('Please enter your name');
  } else {
    setPage(page + 1);
    console.log(formData);
  }
}

Here, if the name field is empty or if the length is less than 1, then an alert box is displayed.

Similarly, we add the following validation to the second page, which consists of an email address field and a field for employment_status:

else if (page === 1) {
  if (formData.email === '' || !formData.email.includes('@')) {
    return alert('Please enter a valid email');
  } else if (!formData.employment_status) {
    return alert('Please select your employment status');
  } else {
    setPage(page + 1);
    console.log(formData);
  }
}

Here, we will first check if the email exists or if the email contains the “@” symbol. If it passes the first check, then we will check if the employment_status field contains anything; otherwise we increment page and log the data.

For the last page, we simply want to display the form data, hence it’s enough to set the page to 0 and clear all the input fields.

else if (page === 2) {
  setPage(0);
  console.log(formData);
  setFormData({
     name: '',
     email: '',
     employment_status: null,
  });
}

That takes care of all our validation!

Setting up a Supabase instance

Let’s send our form data to a new Supabase instance. Supabase is an open source alternative to Firebase. Head over to Supabase to create a new account if you’re new.

Supabase Instance Create New Project

After creating the project, head over to the home page of your newly created project and grab the anon key and url.

(Note: If you cannot find your keys, try finding them at Settings > API)

Supabase Project API Keys

With that complete, now install the Supabase SDK, using:

npm install @supabase/supabase-js

Make a new file at ./lib/supabase.js and export the Supabase client, as shown below.

import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_KEY
)

For the environment variables, create a new file in the root with the name .env.local and pass in the keys.

NEXT_PUBLIC_SUPABASE_KEY=<Your key>
NEXT_PUBLIC_SUPABASE_URL=<Your url>

Sending data to a Supabase table

Go back to the newly created Supabase instance, and create a new table in the Table editor.

Supabase Instance Table Editor

To handle the Supabase logic, we will create a separate Next.js API route, hence making a POST request through the native Fetch API.

Go ahead and make a new Next.js API route at ./pages/api/post-supabase.js.

async function handler (req, res) {
  // server-side logic
}

export default handler;

Now, since we have an API route through which we pass our form data, we can add another layer of protection to validate our form data. The client-side validation is not always reliable, so you should always implement server-side validation as well before saving the user data to the Supabase table.

Inside the handler function, we will first check if the fetch method is POST.

async function handler (req, res) {
  if(req.method === 'POST') {
    // post logic
    return res.status(200).json({message: "Success"});
  }
}

Next, destructure all form data from the request body, as shown here:

if(req.method === "POST") {
  const { name, email, employment_status } = req.body;
}

Next up, we will perform the server-side validation:

if(req.method === "POST") {
  const { name, email, employment_status } = req.body;

    // server-side validation
    if (!name || name.length <= 3) {
      return res.status(400).json({
        status: 'fail',
        message: 'Please enter your name',
      });
    } else if (!email || !email.includes('@')) {
      return res.status(400).json({
        status: 'fail',
        message: 'Please enter a valid email',
      });
    } else if (!employment_status) {
      return res.status(400).json({
        status: 'fail',
        message: 'Please select your employment status',
      });
    }
}

In this case, if anyone tries to tamper with the client-side validation or perform an MITM attack, the server will still be able to deny the form data, and send back an error response.

With the validation complete, all that’s left is to send over the form data to the Supabase table. To do this, import the Supabase client and call the insert method.

import { supabase } from 'utils/supabase';

async function handler (req, res) {
  // ...server-side validation 
  try {
    await supabase.from('formdata').insert({
      name,
      email,
      employment_status,
    });
    return res
        .status(200)
        .json({ success: true, message: 'Post successful' });
  } catch (error) {
    console.log(error.response.data.message);
    return res.status(500).json({
        status: 'fail',
        message: 'Something went wrong',
      });
  }
}

.from is the table to which you want to insert the data. Now this is done, we will make a POST request to this API route from the client-side when the user clicks the button to submit.

Go back to the handleSubmit function, where we wrote the submit logic for page === 2. Let’s call a fetch request to the API route /api/post-supabase:

try {
    await fetch('/api/post-supabase', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(formData),
  });
    setFormData({
    name: '',
    email: '',
    mployment_status: null,
    });
    setPage(0);
  } catch (error) {
    console.log(error);
}

If the fetch method runs as expected, we will reset the form data and set page to 0, and if anything goes wrong, we’ll simply log the error (you can display this as an alert as well).

Go ahead and and try out the multi-step form — you should be able to see a new entry in the Supabase table.

Conclusion

In this blog post we have learned how to create a multi-step form. Using the useState Hook, we have managed to create forms that can be easily manipulated.

We can create forms that have validation and can be easily reused without the need to create new forms for different use cases. Most importantly, we have built this multi-step form without any third-party packages like React-Stepzilla.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Georgey V B I'm a self-taught web developer from India. I enjoy learning about new web technologies and working on projects. I hope that my work will assist other developers who are experiencing the same difficulties that I am.

Leave a Reply