Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Advanced Multistep Forms with React
Alex K.
Alex K.

Posted on • Originally published atclaritydev.net

     

Advanced Multistep Forms with React

In theprevious post we have built a simple registration multistep form with React and React Hook Form. The form works fine for a simple signup workflow where the users don't need to navigate back and forth between steps. In this post, we'll consider the kind of form where the order of steps is not fixed and the users do not need to provide all the information at once. To make it simpler, we'll build on the form example from the previous tutorial, even though this workflow might not be the best for the registration form. Instead, let's imagine that we have a checkout form, where the users fill the info in steps and may save the form in a draft state to come back to it later. The final result can be tested onCodesandbox.

Saving form data on step navigation

At the end of the previous post, we identified several improvements to the form that would make it more flexible. Firstly, when moving from one step to another, the entered data is not saved (and the users don't get any feedback about that). Secondly, navigating by clicking on a step bypasses the form validation, allowing users to submit an incomplete form.

In this post we'll make a few improvements to the form to address the above concerns:

  • Remove field validation for each step and instead show their state in the Stepper.
  • Save the entered form data on step change, so the data is not lost on navigation (the entered data, however, is not saved when clicking thePrevious button).
  • Highlight the missing fields in the confirm page, so the user can go back and fill them in if needed.

We start by removing all the validation from the steps, instead, it will be done in the last step. Next, we'll need to submit the form data when the user clicks on a step. There are several ways to go about this. What we'll do is treat the navigation between steps the same way as theNext button - it will save the current data to the shared context. To achieve this we'll need to somehow trigger the Button'sonClick event from theStepper component. This is where the obscureuseImperativeHandle hook becomes useful. In short, this hook allows calling the ref target's methods outside its component (e.g. from parent components).

First, we wrap theButton,Stepper, and individual step components inforwardRef to enable receivingref as a prop, e.g.:

//Steps/Contact.jsexportconstContact=forwardRef((props,ref)=>{//..return(<FormonSubmit={handleSubmit(saveData)}>            //..<Buttonref={ref}>Next{">"}</Button></Form>);});
Enter fullscreen modeExit fullscreen mode

Secondly, we'll set up theuseImperativeHandle hook insideButton, exposing Button'sclick event.

//Forms/Button.jsimport{forwardRef,useImperativeHandle,useRef}from"react";exportconstButton=forwardRef(({children,variant="primary",...props},ref)=>{constbuttonRef=useRef();useImperativeHandle(ref,()=>({click:()=>{buttonRef.current.click();},}));return(<buttonclassName={`btn btn-${variant}`}{...props}ref={buttonRef}>{children}</button>);});
Enter fullscreen modeExit fullscreen mode

Lastly, we'll create a sharedref at the App's level, and anonStepChange callback which will be assigned to each Link'sonClick. We could have definedonStepChange directly insideStepper, but then we'd need to also wrap it inforwardRef to be able to accept thebuttonRef.

//App.jsexportconstApp=()=>{constbuttonRef=useRef();constonStepChange=()=>{buttonRef.current.click();};return(<AppProvider><Router><StepperonStepChange={onStepChange}/><Routes><Routepath="/"element={<Contactref={buttonRef}/>}/><Routepath="/education"element={<Educationref={buttonRef}/>}/><Routepath="/about"element={<Aboutref={buttonRef}/>}/><Routepath="/confirm"element={<Confirm/>}/></Routes></Router></AppProvider>);};
Enter fullscreen modeExit fullscreen mode

The form seems to work properly and the data is saved when we navigate to another step. However, there's one issue - you might have noticed that sometimes when we want to go a few steps forward, e.g. fromContact toConfirm, the form navigates only one step at a time. This happens because we have two conflicting kinds of navigation - one from the Stepper'sNavLink and another from the form'sonSubmit callback. To fix this, we'll tweak theForm component, so it has a customonSubmit callback and handles the navigation to the next step. This way, by the time the navigation is triggered from theForm, the stepper navigation is already in progress and the Form's navigation is discarded.

//Forms/Form.jsimport{useNavigate}from"react-router-dom";exportconstForm=({children,onSubmit,nextStep,...props})=>{constnavigate=useNavigate();constonSubmitCustom=(e)=>{e.preventDefault();onSubmit();navigate(nextStep);};return(<formclassName="row"onSubmit={onSubmitCustom}{...props}noValidate>{children}</form>);};
Enter fullscreen modeExit fullscreen mode

Now we need to providenextStep to the form from each step to complete the navigation. The updated steps will look like this:

//Steps/Contact.jsimport{forwardRef}from"react";import{useForm}from"react-hook-form";import{useAppState}from"../state";import{Button,Field,Form,Input}from"../Forms";exportconstContact=forwardRef((props,ref)=>{const[state,setState]=useAppState();const{handleSubmit,register}=useForm({defaultValues:state,mode:"onSubmit",});constsaveData=(data)=>{setState({...state,...data});};return(<FormonSubmit={handleSubmit(saveData)}nextStep={"/education"}><fieldset><legend>Contact</legend><Fieldlabel="First name"><Input{...register("firstName")}id="first-name"/></Field><Fieldlabel="Last name"><Input{...register("lastName")}id="last-name"/></Field><Fieldlabel="Email"><Input{...register("email")}type="email"id="email"/></Field><Fieldlabel="Password"><Input{...register("password")}type="password"id="password"/></Field><Buttonref={ref}>Next{">"}</Button></fieldset></Form>);});
Enter fullscreen modeExit fullscreen mode

You can notice that, apart from removing field validation, we have also removed the password confirm field to simplify the form. This validation is now moved to the final step -Confirm. To streamline the rendering and validation, we'll store the whole form data to be rendered as an array of field objects, divided into sections.

//Steps/Confirm.jsimport{useForm}from"react-hook-form";import{useAppState}from"../state";import{Button,Form,Section,SectionRow}from"../Forms";exportconstConfirm=()=>{const[state]=useAppState();const{handleSubmit}=useForm({defaultValues:state});constsubmitData=(data)=>{console.info(data);// Submit data to the server};constdata=[{title:"Personal info",url:"/",items:[{name:"First name",value:state.firstName,required:true},{name:"Last name",value:state.lastName},{name:"Email",value:state.email,required:true},{name:"Password",value:!!state.password?"*****":"",required:true,},],},{title:"Education",url:"/education",items:[{name:"University",value:state.university},{name:"Degree",value:state.degree},],},{title:"About",url:"/about",items:[{name:"About me",value:state.about}],},];return(<FormonSubmit={handleSubmit(submitData)}><h1className="mb-4">Confirm</h1>{data.map(({title,url,items})=>{return(<Sectiontitle={title}url={url}key={title}>{items.map(({name,value})=>{return(<SectionRowkey={name}><div>{name}</div><div>{value}</div></SectionRow>);})}</Section>);})}<divclassName="clo-md-12 d-flex justify-content-start"><Button>Submit</Button></div></Form>);};
Enter fullscreen modeExit fullscreen mode

It should be noted that while this looks cleaner than defining all the sections and items separately in JSX, it can easily become hard to manage when requirements change, e.g. if there's some extra rendering logic added.

A new addition to the items array is arequired field, which we'll use to disable form submission if any of the required fields are missing and to highlight which fields are required.

To achieve the former we iterate over all the items and see if any of the required fields are empty.

//Steps/Confirm.jsconstdisableSubmit=data.some((section)=>section.items.some((item)=>item.required&&!item.value));
Enter fullscreen modeExit fullscreen mode

After we can pass this value to the form'sSubmit button to control itsdisabled state.

//Steps/Confirm.js<Buttondisabled={disableSubmit}>Submit</Button>
Enter fullscreen modeExit fullscreen mode

Making the required fields more visible will be achieved by highlighting the field name (we'll use Boostrap's warning yellow color) and showing an exclamation mark in place of the field's value.

//Steps/Confirm.js<Sectiontitle={title}url={url}key={title}>{items.map(({name,value,required})=>{constisMissingValue=required&&!value;return(<SectionRowkey={name}><divclassName={isMissingValue?"text-warning":""}>{name}</div><div>{isMissingValue?(<spanclassName={"warning-sign"}>!</span>):(value)}</div></SectionRow>);})}</Section>
Enter fullscreen modeExit fullscreen mode

As a result, we get a nice highlight for the required fields:

Form screenshot

Displaying step state in the Stepper

As a final visual touch, we could also display the state of each step in the navigation. If the step has not been visited, it won't have any styling, otherwise, for steps with missing fields we'll show the warning icon and for the steps with no missing required fields a success icon.

Firstly, we'll start tracking the visited steps in the context. Then we can use this data to display the appropriate state indicator. To make the code less cluttered let's create a helper component for rendering step state.

//Steps/Stepper.jsconstStepState=({showWarning,showSuccess})=>{if(showWarning){return<spanclassName={"warning-sign"}>!</span>;}elseif(showSuccess){return(<divclassName="checkmark"><divclassName="circle"></div><divclassName="stem"></div><divclassName="tick"></div></div>);}else{returnnull;}};
Enter fullscreen modeExit fullscreen mode

Nothing fancy here, we just render the state icon based on the value of boolean props. To track the visited steps we can leverage the hooks from React Router and save the pathname from the current location as a visited step.

//Steps/Stepper.jsconstlocation=useLocation();const[steps,setSteps]=useState([]);useEffect(()=>{setSteps((steps)=>[...steps,location.pathname]);},[location]);
Enter fullscreen modeExit fullscreen mode

We use thefunctional form ofsetState here to avoid declaring it as one of theuseEffect dependencies (which can break the app due to infinite rendering loop). We do not care if the visited steps are unique, we could check if the step is already added before adding a new one, but that seems like a minor optimization.

Finally, we can add the step state indicators to the Stepper. For easier rendering, let's collect the data for the nav links into an array of objects and render them in a loop.

//Steps/Stepper.jsimport{useEffect,useState}from"react";import{NavLink,useLocation}from"react-router-dom";import{useAppState}from"../state";exportconstStepper=({onStepChange})=>{const[state]=useAppState();constlocation=useLocation();const[steps,setSteps]=useState([]);useEffect(()=>{setSteps((steps)=>[...steps,location.pathname]);},[location]);constgetLinkClass=({isActive})=>`nav-link${isActive?"active":undefined}`;constcontactInfoMissing=!state.firstName||!state.email||!state.password;constisVisited=(step)=>steps.includes(step)&&location.pathname!==step;constnavLinks=[{url:"/",name:"Contact",state:{showWarning:isVisited("/")&&contactInfoMissing,showSuccess:isVisited("/")&&!contactInfoMissing,},},{url:"/education",name:"Education",state:{showSuccess:isVisited("/education"),},},{url:"/about",name:"About",state:{showSuccess:isVisited("/about"),},},{url:"/confirm",name:"Confirm",state:{},},];return(<navclassName="stepper navbar navbar-expand-lg"><divclassName="collapse navbar-collapse"><olclassName="navbar-nav">{navLinks.map(({url,name,state})=>{return(<liclassName="step nav-item"key={url}><StepStateshowWarning={state.showWarning}showSuccess={state.showSuccess}/><NavLinkendto={url}className={getLinkClass}onClick={onStepChange}>{name}</NavLink></li>);})}</ol></div></nav>);};constStepState=({showWarning,showSuccess})=>{if(showWarning){return<spanclassName={"warning-sign"}>!</span>;}elseif(showSuccess){return(<divclassName="checkmark"><divclassName="circle"></div><divclassName="stem"></div><divclassName="tick"></div></div>);}else{returnnull;}};
Enter fullscreen modeExit fullscreen mode

TheEducation andAbout steps will have a success state if they have been visited since they do not have any required fields. It should be pretty easy to add validation for those steps if necessary or otherwise extend the validation (e.g. validate email or password).

Conclusion

Now that the step state highlight is working we have a functional multistep form, that could have a wide range of use cases. As a future improvement, we could add a "Save as draft" functionality that would save entered incomplete data to the local storage (or a database) and allow the users to come back to it later.

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Full Stack/ Front End web developer. React/Redux, Styled components, Node.js, Django.
  • Location
    Helsinki
  • Work
    Frontend developer
  • Joined

More fromAlex K.

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp