DEV Community

Cover image for Minimal Multi-Stage Form Wizard with AI Review
Pratyush Srivastava
Pratyush Srivastava

Posted on

Minimal Multi-Stage Form Wizard with AI Review

Introduction:

I am actually trying to brush up my react knowledge, so I am making these simple yet practical projects, and in sequence I made a minimal multi step form wizard, which has AI based review built in. I have used openrouter.ai for AI model and model used is QWEN3 similar to my previous project,
If you are interested, here is the - AI powered note taker & summarizer

Instead of telling you all about it now, let's go through everything one by one.

Preview

Features:

  • Multi step form
  • AI based data review
  • Custom hook for tracking progress
  • Data persistence in localStorage
  • Clean and Minimal UI using shadcn

Libraries used

  • Shadcn
  • Tailwindcss
  • react-markdown
  • zustand

Folder Structure

๐Ÿ“ฆ project-root
โ”‚
โ”œโ”€โ”€ ๐Ÿ“‚ node_modules/          # Auto-installed dependencies
โ”œโ”€โ”€ ๐Ÿ“‚ public/                # Static assets (served directly)
โ”‚
โ”œโ”€โ”€ ๐Ÿ“‚ src/                   # Main source code
โ”‚   โ”œโ”€โ”€ ๐Ÿ“‚ components/         # Reusable building blocks
โ”‚   โ”‚   โ”œโ”€โ”€ ๐Ÿ“‚ ui/             # UI elements (buttons, inputs, etc. from shadcn)
โ”‚   โ”‚   โ”œโ”€โ”€ ๐Ÿ“‚ hooks/          # Custom React hooks
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ useFormProgress.js   # Hook to track form progress
โ”‚   โ”‚   โ”œโ”€โ”€ ๐Ÿ“‚ lib/            # Helper libs / utilities (empty now)
โ”‚   โ”‚   โ””โ”€โ”€ ๐Ÿ“‚ Pages/          # Page-level components
โ”‚   โ”‚       โ”œโ”€โ”€ PageOne.jsx    # Page 1
โ”‚   โ”‚       โ”œโ”€โ”€ PageTwo.jsx    # Page 2
โ”‚   โ”‚       โ””โ”€โ”€ PageThree.jsx  # Page 3
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ ๐Ÿ“‚ store/              # Global state management
โ”‚   โ”‚   โ””โ”€โ”€ store.js           # Zustand/Redux store setup
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ ๐Ÿ“‚ utils/              # Utility/helper functions
โ”‚   โ”‚   โ””โ”€โ”€ aiReview.js        # contains AI helper function
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ App.jsx                # Root React component
โ”‚   โ”œโ”€โ”€ index.css              # Global styles
โ”‚   โ””โ”€โ”€ main.jsx               # Entry point (renders App)
โ”‚
โ”œโ”€โ”€ .env                       # Environment variables
โ”œโ”€โ”€ .gitignore                 # Git ignore rules
โ”œโ”€โ”€ components.json            # UI/Component config
โ”œโ”€โ”€ eslint.config.js           # Linting rules
โ”œโ”€โ”€ index.html                 # Main HTML file (Vite entry)
โ”œโ”€โ”€ jsconfig.json              # Path aliases/config, imp for shadcn
โ”œโ”€โ”€ package.json               # Project metadata & dependencies
โ”œโ”€โ”€ package-lock.json          # Dependency lock file
โ”œโ”€โ”€ README.md                  # Project documentation
โ””โ”€โ”€ vite.config.js             # Vite bundler config
Enter fullscreen mode Exit fullscreen mode

Now let's break down every file bit by bit, and Let's start with the entry point


App.jsx:

It's the parent component and contains all the pages, and it also contains the progress bar, that gets filled according to our custom useFormProgress hook(that we'll see a little bit later), but for the time being, the hook returns

  • progress,
  • currentStep(page), and
  • totalSteps,

value of progress is calculated based on this simple calculation,

Math.round((completedSteps / totalSteps) * 100)
Enter fullscreen mode Exit fullscreen mode
import { useStore } from './store/store'
import PageOne from './Pages/PageOne'
import PageTwo from './Pages/PageTwo'
import PageThree from './Pages/PageThree'
import { useEffect, useRef } from 'react'
import useFormProgress from './hooks/useFormProgress'
import { Progress } from "@/components/ui/progress"

const App = () => {
  const { page, formData, setData } = useStore()
  const { progress, currentStep, totalSteps } = useFormProgress()
  const pageStyle = {
    width: "100%",
    height: "100vh",
    display: "flex",
    alignItems: "center",
    justifyContent: "center"
  }
  return (
    <div>
      <div className='p-2'>
        <Progress value={progress} />
      </div>
      <p className='p-2 font-semibold'>Step {currentStep} of {totalSteps} โ€” {progress}%</p>
      {page === 1 && <div style={pageStyle}>
        <PageOne />
      </div>}
      {page === 2 && <div style={pageStyle}>
        <PageTwo />
      </div>}
      {page === 3 && <div style={pageStyle}>
        <PageThree />
      </div>}
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Now let's move on to our pages


PageOne.jsx

PageOne is our first page in the app, it has two important hooks we need to consider,

first is the useReducer hook that I used for very very basic form validation. The hook takes an initial state and a reducer, that basically have some action types that will return something specific based on the matching action type, and

second is the useEffect hook that contains some useful code, firstly we take setData function from the zustand store,

setData: data => set(state => {
        const obj = { formData: { ...state.formData, ...data } }
        localStorage.setItem('formData', JSON.stringify(obj))
        return obj
    }),
Enter fullscreen mode Exit fullscreen mode

and we check whether local storage has formData if it is already there we set it in store, otherwise we provide initial state to store so that we do not get any null based errors later (believe me it gets quite bad).

Then I just took all the fields and used dispatch provided by the useReducer hook to set the state so form will repopulate with data even if page is refreshed.

import { useState, useReducer, useEffect } from 'react'
import { useStore } from "../store/store"
import { Input } from "@/components/ui/Input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
    Card,
    CardAction,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
} from "@/components/ui/card"

const initialState = {
    name: {
        value: '',
        touched: '',
        error: null
    },
    email: {
        value: '',
        touched: '',
        error: null
    },
    phone: {
        value: '',
        touched: '',
        error: null
    }
}

const reducer = (state, action) => {
    switch (action.type) {
        case 'name':
            return {
                ...state, name: {
                    value: action.payload.value,
                    touched: true,
                    error: action.payload.error
                }
            }
        case 'email':
            return {
                ...state, email: {
                    value: action.payload.value,
                    touched: true,
                    error: action.payload.error
                }
            }
        case 'phone':
            return {
                ...state, phone: {
                    value: action.payload.value,
                    touched: true,
                    error: action.payload.error
                }
            }
        default:
            throw new Error(`Unknown action type: ${action.type}`)
    }
}

const PageOne = () => {
    const { page, changePage, setData, formData } = useStore()
    const [state, dispatch] = useReducer(reducer, initialState)
    useEffect(() => {
        const saved = localStorage.getItem('formData')

        const initialData = saved ? JSON.parse(saved).formData : {
            name: '',
            email: '',
            phone: '',
            preferences: []
        }

        setData(initialData)

        const { name, email, phone } = initialData

        dispatch({
            type: "name", payload: {
                value: name,
                error: null
            }
        })
        dispatch({
            type: "email", payload: {
                value: email,
                error: null
            }
        })
        dispatch({
            type: "phone", payload: {
                value: phone,
                error: null
            }
        })

    }, [])
    return (
        <Card className='w-full max-w-md'>
            <CardHeader>
                <CardTitle>Enter Basic Information</CardTitle>
                <CardDescription>Please provide basic information, like name, email and phone number</CardDescription>
                <CardAction>Step 1</CardAction>
            </CardHeader>
            <CardContent>
                <div className=''>
                    <Label className='p-2 text-md' htmlFor="">Name</Label>
                    <Input type="text" name="name"
                        className={state.name.error ? 'error' : ''}
                        value={state.name.value}
                        onChange={e => dispatch({
                            type: 'name', payload: {
                                value: e.target.value,
                                error: state.name.touched ? e.target.value.length === 0 : null
                            }
                        })}
                    />
                </div>
                <div className=''>
                    <Label className='p-2 text-md' htmlFor="email">Email</Label>
                    <Input type="email" name="email"
                        className={state.email.error ? 'error' : ''}
                        value={state.email.value}
                        onChange={e => dispatch({
                            type: 'email', payload: {
                                value: e.target.value,
                                error: state.email.touched ? e.target.value.length === 0 : null
                            }
                        })}
                    />
                </div>
                <div>
                    <Label className="p-2 text-md" htmlFor="phone">Phone</Label>
                    <Input type="number" name="phone"
                        className={state.phone.error ? 'error' : ''}
                        value={state.phone.value}
                        onChange={e => dispatch({
                            type: 'phone', payload: {
                                value: e.target.value,
                                error: state.phone.touched ? e.target.value.length === 0 : null
                            }
                        })}
                    />
                </div>
            </CardContent>
            <CardFooter>

                <Button className='w-20 h-10' onClick={() => {
                    changePage('next')
                    setData({
                        name: state.name.value,
                        email: state.email.value,
                        phone: state.phone.value
                    })
                }}>Next</Button>

            </CardFooter>
        </Card>
    )
}

export default PageOne
Enter fullscreen mode Exit fullscreen mode

PageTwo.jsx

It is used to get preferences from the user, that I have divided them in objects of different types according to input used, namely

  • checkboxes,
  • selectors, and
  • a single string named notification

I have used a useEffect hook to basically get the data from localstorage and fill them in their respective input (I know using persist from zustand is better but I just wanted to use this to see if it works or not), and

after user fill in the fields, next button will save preferences using setPreferences function from the zustand store.

import { useStore } from "../store/store"
import { useEffect, useState } from 'react'
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Select } from "@/components/ui/select"
import { Card, CardAction, CardTitle, CardHeader, CardDescription, CardContent, CardFooter } from "@/components/ui/card"

const PageTwo = () => {
  const { changePage, setPreferences, formData } = useStore()
  const [checkboxes, setCheckboxes] = useState({})
  const [selectors, setSelectors] = useState({
    language: '',
    theme: ''
  })
  const [notification, setNotification] = useState('daily')

  useEffect(() => {
    const { preferences } = formData

    if (preferences.length === 0) {
      return
    }
    const keys = Object.keys(...preferences)

    if (keys.includes('notification')) {
      setNotification(preferences[0].notification)
    }
    if (keys.includes('language')) {
      setSelectors(prev => ({
        ...prev,
        language: preferences[0].language
      }))
    }
    if (keys.includes('theme')) {
      setSelectors(prev => ({
        ...prev,
        theme: preferences[0].theme
      }))
    }
    if (keys.includes('newsletter')) {
      setCheckboxes(prev => ({
        ...prev,
        newsletter: preferences[0].newsletter
      }))
    }
    if (keys.includes('dark')) {
      setCheckboxes(prev => ({
        ...prev,
        dark: preferences[0].dark
      }))
    }
    if (keys.includes('updates')) {
      setCheckboxes(prev => ({
        ...prev,
        updates: preferences[0].updates
      }))
    }
  }, [])

  const handleCheckboxes = e => {
    setCheckboxes(prev => ({
      ...prev,
      [e.target.name]: e.target.checked
    }))
  }

  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Enter Preferences</CardTitle>
        <CardDescription>Please provide preferences.</CardDescription>
        <CardAction>Step 2</CardAction>
      </CardHeader>
      {/* checkboxes */}
      <CardContent className="flex flex-col gap-4">
        <div className="flex flex-col gap-2">
          <div className="flex items-center justify-between">
            <Label className="text-sm" htmlFor="">Recieve Newsletter</Label>
            <input type="checkbox" name="newsletter" checked={checkboxes.newsletter} onChange={handleCheckboxes} />
          </div>
          <div className="flex items-center justify-between">
            <Label className="text-sm" htmlFor="">Dark Mode By Default</Label>
            <input type="checkbox" name="dark" checked={checkboxes.dark} onChange={handleCheckboxes} />
          </div>
          <div className="flex items-center justify-between">
            <Label className="text-sm" htmlFor="">Recieve Product Updates</Label>
            <input type="checkbox" name="updates" checked={checkboxes.updates} onChange={handleCheckboxes} />
          </div>
        </div>
        {/* selectors */}
        <div className="flex flex-col gap-2">
          <div className="flex items-center justify-between">
            <Label className="text-sm" htmlFor="">Select Language</Label>
            <select className="border border-gray-200 p-.5" value={selectors.language} onChange={e => setSelectors(prev => ({ ...prev, language: e.target.value }))}>
              <option value="" disabled>Select</option>
              <option value="hindi">Hindi</option>
              <option value="english">English</option>
            </select>
          </div>
          <div className="flex items-center justify-between">
            <Label className="text-sm" htmlFor="">Theme</Label>
            <select className="border border-gray-200 p-.5" value={selectors.theme} onChange={(e) => setSelectors(prev => ({ ...prev, theme: e.target.value }))}>
              <option value="" disabled>Select</option>
              <option value="light">Light</option>
              <option value="dark">Dark</option>
              <option value="system">System</option>
            </select>
          </div>
        </div>
        {/* radio buttons */}

          <div className="flex flex-col gap-1">
            <Label className="text-sm underline" htmlFor="">Notification Frequency</Label>
            <div className="flex items-center justify-between">
              <Label className="text-sm" htmlFor="">Daily</Label>
              <input type="radio" name="" value='daily' checked={notification === 'daily'} onChange={e => setNotification(e.target.value)} />
            </div>
            <div className="flex items-center justify-between">
              <Label className="text-sm" htmlFor="">Monthly</Label>
              <input type="radio" name="" value='monthly' checked={notification === 'monthly'} onChange={e => setNotification(e.target.value)} />
            </div>
            <div className="flex items-center justify-between">
              <Label className="text-sm" htmlFor="">Weekly</Label>
              <input type="radio" name="" value='weekly' checked={notification === 'weekly'} onChange={e => setNotification(e.target.value)} />
            </div>
          </div>

      </CardContent>

      <CardFooter className='flex gap-2'>

          <Button onClick={() => changePage('prev')}>Prev</Button>
          <Button onClick={() => {
            changePage('next')
            setPreferences({
              ...selectors,
              ...checkboxes,
              notification
            })
          }}>Next</Button>

      </CardFooter>
    </Card>
  )
}

export default PageTwo
Enter fullscreen mode Exit fullscreen mode

PageThree.jsx

PageThree is the lastpage of the multi step form, it contains the submission button and AI review buttons.

AI review: When user clicks review button the side panel will open and I have placed a skeleton(provided by shadcn) as a loader, that will load until we get response from the API, after that it shows all the recommendation, suggestions provided by the AI model.

Submission: When user clicks the submission button, it basically calls the setSubmit function that will set the formData state into the initial state.

setSubmit: () => set(state => {
        const initialState = {
            name: '',
            email: '',
            phone: '',
            preferences: []
        }
        localStorage.setItem("formData", JSON.stringify({formData: initialState}))
        return initialState
    })
Enter fullscreen mode Exit fullscreen mode
import { useEffect, useState } from "react"
import { useStore } from "../store/store"
import { aiReview } from "../utils/aiReview"
import Markdown from "react-markdown"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardAction, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"

const PageThree = () => {
  const { changePage, formData, setSubmit } = useStore()
  const [review, setReview] = useState('')
  const [submitted, setSubmitted] = useState(false)
  const [loading, setLoading] = useState(false)

  const handleReview = async () => {
    try {
      setLoading(true)
      const res = await aiReview(JSON.stringify(formData))
      if (res.ok) {
        const data = await res.json()
        const { content } = data.choices[0].message
        setReview(content)
        setLoading(false)
        console.log("response", data, content)
      }
    } catch (error) {
      throw new Error('Error in review, ', error)
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    if (submitted) {
      setTimeout(() => {
        window.location.href = '/'
      }, 2000)
    }
  }, [submitted])

  if (submitted) {
    return (
      <div>Submitted</div>
    )
  } else {
    return (
      <div className="flex w-full max-w-xl gap-2">
        <Card className="w-full max-w-md">
          <CardHeader>
            <CardTitle>Preview</CardTitle>
            <CardDescription>This is the final step, please review the form before submission</CardDescription>
            <CardAction>Final Step</CardAction>
          </CardHeader>
          <CardContent className='flex flex-col gap-2'>
            <div>
              <h2 className="font-semibold text-md">Basic Info</h2>
            </div>
            <div className="text-sm">
              <p>Name: {formData.name}</p>
              <p>Email: {formData.email}</p>
              <p>Phone: {formData.phone}</p>
            </div>
            <div>
              <h2 className="text-md font-semibold">Preferences</h2>
            </div>
            <div className="flex items-center justify-between text-sm">
              <div>
                {Object.keys(formData.preferences[0]).map(item => <p>{item}</p>)}
              </div>
              <div>
                {Object.values(formData.preferences[0]).map(item => {
                  if (typeof item === 'boolean') {
                    return (
                      <p className="text-green-500">&#10003;</p>
                    )
                  }
                  return (
                    <p>{item}</p>
                  )
                })}
              </div>
            </div>
          </CardContent>
          <CardFooter className="flex gap-2">
            <Button onClick={() => changePage("prev")}>Prev</Button>
            {!review && <Button onClick={handleReview}>Review</Button>}
            <Button onClick={() => {
              setSubmit()
              setSubmitted(true)
            }}>Submit</Button>
          </CardFooter>
        </Card>
        {loading && <Card className="w-full max-w-md h-[450px] overflow-hidden overflow-y-scroll">
          <CardHeader>
            <CardTitle>AI Review</CardTitle>
          </CardHeader>
          <CardContent className="flex flex-col gap-4"><Skeleton className="h-4" /><Skeleton className="h-4" /><Skeleton className="h-4" /><Skeleton className="h-4" /></CardContent>
        </Card>}
        {!loading && review && <Card className="w-full max-w-md h-[450px] overflow-hidden overflow-y-scroll">
          <CardHeader>
            <CardTitle>AI Review</CardTitle>
          </CardHeader>
          <CardContent>
            <Markdown>{review}</Markdown>
          </CardContent>
        </Card>}
      </div>
    )
  }
}

export default PageThree
Enter fullscreen mode Exit fullscreen mode

Store.js

Ahh, storejs, this is the where the zustand store resides, there are only two states in this,

first is the page that shows which page is we are currently and

second is the formData that contains basic details as well as preferences.

Now let's talk about all the methods it has:

changePage: Although I sort of started, and make this like a carousel of three pages, I later removed option to move after third page, so this

if (command === 'next' && state.page === 3) {
            return {
                page: 1
            }
    }
Enter fullscreen mode Exit fullscreen mode

is pretty much useless now ๐Ÿ˜…, and the function changePage performs if basically moving between pages.

setData and setPreferences: They both manipulate formData state, setData changes only basic details name, email, phone, whereas setPreferences is what it suggests set preferences.

setSubmit: It changes the formData to the initial version so basically reset all the data.

import { create } from "zustand";

export const useStore = create(set => ({
    page: 1,
    formData: {
        name: "",
        email: "",
        phone: "",
        preferences: []
    },
    changePage: (command) => set(state => {
        if (command === 'next' && state.page === 3) {
            return {
                page: 1
            }
        }
        if (command === 'next') {
            return {
                page: state.page + 1
            }
        }
        if (command === 'prev' && state.page === 1) {
            return {
                page: 3
            }
        }
        if (command === 'prev') {
            return {
                page: state.page - 1
            }
        }
    }),
    setData: data => set(state => {
        const obj = { formData: { ...state.formData, ...data } }
        localStorage.setItem('formData', JSON.stringify(obj))
        return obj
    }),
    setPreferences: data => set(state => {
        const obj = { formData: { ...state.formData, preferences: [data] } }
        localStorage.setItem('formData', JSON.stringify(obj))
        return obj
    }),
    setSubmit: () => set(state => {
        const initialState = {
            name: '',
            email: '',
            phone: '',
            preferences: []
        }
        localStorage.setItem("formData", JSON.stringify({formData: initialState}))
        return initialState
    })
}))
Enter fullscreen mode Exit fullscreen mode

useFormProgress.jsx

This is the custom hook that is responsible for the progress bar on top of the page,

it is quite simple and easy to implement, what I did is first I checked whether step 1 form has all the inputs filled in,

const step1Complete = formData.name && formData.email && formData.phone

then for preferences array, we check first is an array Array.isArray(formData.preferences),

then it has some value or not formData.preferences.length > 0 and

then it has some objects with truthy values or not Object.values(formData.preferences[0]).some(Boolean),

after that I checked which steps are completed by filtering the truth values,
[step1Complete, step2Complete, step3Complete].filter(Boolean).length and

that's pretty much it, then just some simple math and its done Math.round((completedSteps / totalSteps) * 100)

import {useStore} from '../store/store'

const useFormProgress =(totalSteps = 3) => {
    const {formData, page} = useStore()

    //step 1
    const step1Complete = formData.name && formData.email && formData.phone

    //step 2
    const step2Complete = Array.isArray(formData.preferences) && formData.preferences.length > 0 && Object.values(formData.preferences[0]).some(Boolean)

    //step 3
    const step3Complete = step1Complete && step2Complete
    const completedSteps = [step1Complete, step2Complete, step3Complete].filter(Boolean).length

    const progress = Math.round((completedSteps / totalSteps) * 100)
    return {
        progress,
        currentStep: page,
        totalSteps,
        completedSteps
    }

}

export default useFormProgress
Enter fullscreen mode Exit fullscreen mode

aiReview.js

This file is what controls the outcome of the AI model, we're basically sending a configuration on how the outcome of the response should be,

{
"role": "system",
"content": "You are a helpful assistant that checks the content of the form data and recommends improvements."
}

this basically tells the model what to do and how to behave, then we just pass the data, and wait for the response

{
"role": "user",
"content": `Check this data: ${content}`
}

here I passed the whole object to check (because I'm lazy as hell ๐Ÿ˜…), but you can format it and use only those parts that you want to send to the model.

export const aiReview = (content) =>  fetch('https://openrouter.ai/api/v1/chat/completions', {
    method: 'POST',
    headers: {
        Authorization: `Bearer ${import.meta.env.VITE_KEY}`,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({
        model: "qwen/qwen3-8b:free",
        messages: [
            {
                "role": "system",
                "content": "You are a helpful assistant that checks the content of the form data and recommends improvements."
            },
            {
                "role": "user",
                "content": `Check this data: ${content}`
            }
        ]
    }),
});
Enter fullscreen mode Exit fullscreen mode

At last, I want to tell you how to run the project, just launch the terminal in VScode and type npm run dev

And THAT'S IT, that is our multi step form wizard with AI based review,

Github Repo

If you like this project and want more, you can follow me here, and/or on insta,
I will be back with more projects, tips, until then, bye ๐Ÿ‘‹

Top comments (0)