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
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)
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
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
}),
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
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
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
})
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">✓</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
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
}
}
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
})
}))
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
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}`
}
]
}),
});
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,
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)