Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Aaron Powell
Aaron Powell

Posted on • Originally published ataaron-powell.com on

     

Creating Dynamic Forms with React Hooks

The other week my friendAmy Kapernick reached out because she was having a problem with React. She was working on a project that used a headless CMS to build and control multi page forms and the fields in it, including conditional fields/pages that appear/hide depending on the value of other fields. The headless CMS would then generate a JSON payload that was pulled into a Gatsby site and needed to be rendered as a React form that a user could walk through. While the form was building and rendering, her problem was working with different bits of state management and making sure to update the right things at the right time, and she needed another set of eyes on the problem.

Having built dynamic form generators in the past, built systems backed by generic form generators, and generally done a lot with dynamic forms, I knew just the sort of pain she was in for so I was happy to help.

So in this post, we'll break down how you can make dynamic forms in React, including how to do conditional control over fields appearing and page navigation.

Defining a data structure

We'll start by defining the data structure that we'll use for this sample, but do keep in mind that the structure will be driven by the backend system the forms are designed in, so you'll need to tweak accordingly.

[{"component":"page","label":"Page 1","_uid":"0c946643-5a83-4545-baea-055b27b51e8a","fields":[{"component":"field_group","label":"Name","_uid":"eb169f76-4cd9-4513-b673-87c5c7d27e02","fields":[{"component":"text","label":"First Name","type":"text","_uid":"5b9b79d2-32f2-42a1-b89f-203dfc0b6b98"},{"component":"text","label":"Last Name","type":"text","_uid":"6eff3638-80a7-4427-b07b-4c1be1c6b186"}]},{"component":"text","label":"Email","type":"email","_uid":"7f885969-f8ba-40b9-bf5d-0d57bc9c6a8d"},{"component":"text","label":"Phone","type":"text","_uid":"f61233e8-565e-43d0-9c14-7d7f220c6020"}]}]
Enter fullscreen modeExit fullscreen mode

The structure we've got here is intended to be simple. It is made from an array of pages, with each page identified by thecomponent value ofpage, and within that is an array offields that contains the inputs, or groups of inputs (again, denoted by thecomponent property).

Creating the form

With the data structure ready, it's time to create the form. We'll start with a new component calledForm:

importReactfrom"react";constForm=({formData})=>{constonSubmit=e=>{e.preventDefault();// todo - send data somewhere};return(<formonSubmit={onSubmit}><p>todo...</p></form>);};exportdefaultForm;
Enter fullscreen modeExit fullscreen mode

For this demo, the form won't submit anywhere, but we'll prevent the default action usingpreventDefault. The component will receive theformData as a prop, so it's up to the parent component to work out how to get the data and pass it over, again, for this demo we'll have it hard coded in the codebase, but for Amy's situation it was being fetched as part of the Gatsby rendering process and included in the output bundle.

Defining state

There's a bit of state that we're going to have to manage in the React components, such as which page of the form we're on and the values of theControlled Components. For this, we'll useHooks so that we can stick with function components.

constForm=({formData})=>{const[page,setPage]=useState(0);const[currentPageData,setCurrentPageData]=useState(formData[page]);constonSubmit=e=>{e.preventDefault();// todo - send data somewhere};return(<formonSubmit={onSubmit}><p>todo...</p></form>);};
Enter fullscreen modeExit fullscreen mode

The first bit of state is the index of the current page, which starts at 0, and the second is the data for the page, plucked from the array, so we don't need to constantly grab it constantly and we can respond to it changing using theuseEffect Hook if required.

Rendering the form fields

Let's start by defining a generic field in a file calledField.jsx:

importReactfrom"react";constField=({field,fieldChanged,type,value})=>{return(<divkey={field._uid}><labelhtmlFor={field._uid}>{field.label}</label><inputtype={type||field.component}id={field._uid}name={field._uid}value={value}onChange={e=>fieldChanged(field._uid,e.target.value)}/></div>);};exportdefaultField;
Enter fullscreen modeExit fullscreen mode

This will render out a label and input in a basic manner, update the HTML to the structure that's required for your design (or render out fields from a form library likeFormik). The two props that are likely to be of most interest as thevalue andfieldChanged. Thevalue prop is the current value for the Controlled Component, which will come from the Form component itself (we've not implemented that yet) andfieldChanged will be used to update this main state list.

Let's go about rendering out the fields in the Form component:

constForm=({formData})=>{const[page,setPage]=useState(0);const[currentPageData,setCurrentPageData]=useState(formData[page]);constonSubmit=e=>{e.preventDefault();// todo - send data somewhere};return(<formonSubmit={onSubmit}><h2>{currentPageData.label}</h2>{currentPageData.fields.map(field=>{switch(field.component){case"field_group":return(<FieldGroupkey={field._uid}field={field}fieldChanged={fieldChanged}values={values}/>);case"options":return(<Optionkey={field._uid}field={field}fieldChanged={fieldChanged}value={values[field._uid]}/>);default:return(<Fieldkey={field._uid}field={field}fieldChanged={fieldChanged}value={values[field._uid]}/>);}})}</form>);};
Enter fullscreen modeExit fullscreen mode

You'll notice a few more types of fields rendered out here, I'll skip their implementations in the blog post, but you can check out thefull sample for them.

We're iterating overcurrentPageData.fields and using aswitch statement to work out what kind of field we want to render based on thefield.component. it's then a matter of passing in the right props. But there's something missing, what arefieldChanged andvalues, they currently don't exist.

Handling user input

To handle the user input, we're going to need two things, somewhere to store that input, and a function to do the updating. Let's start with the storage, which is going to be a new bit of state in Hooks:

constForm=({formData})=>{const[page,setPage]=useState(0);const[currentPageData,setCurrentPageData]=useState(formData[page]);const[values,setValues]=useState({});// snip
Enter fullscreen modeExit fullscreen mode

Thevalues object is going to act as a dictionary so we can dovalues[field._uid] to get the value out for a field, but as per the requirements of a Controlled Component, we need to initialise the value, and we can do that with theuseEffect Hook:

constForm=({formData})=>{const[page,setPage]=useState(0);const[currentPageData,setCurrentPageData]=useState(formData[page]);const[values,setValues]=useState({});// this effect will run when the `page` changesuseEffect(()=>{constupcomingPageData=formData[page];setCurrentPageData(upcomingPageData);setValues(currentValues=>{constnewValues=upcomingPageData.fields.reduce((obj,field)=>{if(field.component==="field_group"){for(constsubFieldoffield.fields){obj[subField._uid]="";}}else{obj[field._uid]="";}returnobj;},{});returnObject.assign({},newValues,currentValues);});},[page,formData]);// snip
Enter fullscreen modeExit fullscreen mode

This Effect has two dependencies,page andformData, so if either changes (although it really will only bepage that changes) it will run. When it runs it'll get the next page we're going to from thepage state value, and set that as the current page usingsetCurrentPageData. Once that's done, we'll initialise any new fields on thevalues state using a callback to thesetValues updater function that uses areduce method to iterate over the fields and builds up a new object containing the newly initialised fields. Finally, it'll merge the newly initialised field values with any existing values to produce the newvalues state.

Tip: usingObject.assign like this will merge the objects in the order specified, meaning the right-most object values will take precedence, so if you navigate backwards on the form, your previous values are still there.

With the values now available to the Controlled Components, all that's left is creating a function to update them.

constForm=({formData})=>{const[page,setPage]=useState(0);const[currentPageData,setCurrentPageData]=useState(formData[page]);const[values,setValues]=useState({});// this effect will run when the `page` changesuseEffect(()=>{constupcomingPageData=formData[page];setCurrentPageData(upcomingPageData);setValues(currentValues=>{constnewValues=upcomingPageData.fields.reduce((obj,field)=>{if(field.component==="field_group"){for(constsubFieldoffield.fields){obj[subField._uid]="";}}else{obj[field._uid]="";}returnobj;},{});returnObject.assign({},newValues,currentValues);});},[page,formData]);constfieldChanged=(fieldId,value)=>{setValues(currentValues=>{currentValues[fieldId]=value;returncurrentValues;});setCurrentPageData(currentPageData=>{returnObject.assign({},currentPageData);});};// snip
Enter fullscreen modeExit fullscreen mode

ThefieldChanged function will receive thefieldId (field._uid) and the newvalue. When called it'll update thevalues state with the new value and then force a render by faking an update of thecurrentPageData state value, usingObject.assign.

We need to fake thecurrentPageData update when the values change so that render phase of our component will be run, if not, themap function won't be aware of the updated values and the inputs will never show the entered data.

Now our full form is looking like this:

constForm=({formData})=>{const[page,setPage]=useState(0);const[currentPageData,setCurrentPageData]=useState(formData[page]);const[values,setValues]=useState({});// this effect will run when the `page` changesuseEffect(()=>{constupcomingPageData=formData[page];setCurrentPageData(upcomingPageData);setValues(currentValues=>{constnewValues=upcomingPageData.fields.reduce((obj,field)=>{if(field.component==="field_group"){for(constsubFieldoffield.fields){obj[subField._uid]="";}}else{obj[field._uid]="";}returnobj;},{});returnObject.assign({},newValues,currentValues);});},[page,formData]);constfieldChanged=(fieldId,value)=>{setValues(currentValues=>{currentValues[fieldId]=value;returncurrentValues;});setCurrentPageData(currentPageData=>{returnObject.assign({},currentPageData);});};constonSubmit=e=>{e.preventDefault();// todo - send data somewhere};return(<formonSubmit={onSubmit}><h2>{currentPageData.label}</h2>{currentPageData.fields.map(field=>{switch(field.component){case"field_group":return(<FieldGroupkey={field._uid}field={field}fieldChanged={fieldChanged}values={values}/>);case"options":return(<Optionkey={field._uid}field={field}fieldChanged={fieldChanged}value={values[field._uid]}/>);default:return(<Fieldkey={field._uid}field={field}fieldChanged={fieldChanged}value={values[field._uid]}/>);}})}</form>);};
Enter fullscreen modeExit fullscreen mode

Adding navigation

Buttons, the form is missing buttons to do anything, be it submit the data or navigate between steps, let's add those now:

constForm=({formData})=>{const[page,setPage]=useState(0);const[currentPageData,setCurrentPageData]=useState(formData[page]);const[values,setValues]=useState({});// this effect will run when the `page` changesuseEffect(()=>{constupcomingPageData=formData[page];setCurrentPageData(upcomingPageData);setValues(currentValues=>{constnewValues=upcomingPageData.fields.reduce((obj,field)=>{if(field.component==="field_group"){for(constsubFieldoffield.fields){obj[subField._uid]="";}}else{obj[field._uid]="";}returnobj;},{});returnObject.assign({},newValues,currentValues);});},[page,formData]);constfieldChanged=(fieldId,value)=>{setValues(currentValues=>{currentValues[fieldId]=value;returncurrentValues;});setCurrentPageData(currentPageData=>{returnObject.assign({},currentPageData);});};constonSubmit=e=>{e.preventDefault();// todo - send data somewhere};return(<formonSubmit={onSubmit}><h2>{currentPageData.label}</h2>{currentPageData.fields.map(field=>{switch(field.component){case"field_group":return(<FieldGroupkey={field._uid}field={field}fieldChanged={fieldChanged}values={values}/>);case"options":return(<Optionkey={field._uid}field={field}fieldChanged={fieldChanged}value={values[field._uid]}/>);default:return(<Fieldkey={field._uid}field={field}fieldChanged={fieldChanged}value={values[field._uid]}/>);}})}{page>0&&(<buttononClick={()=>setPage(page+1)}>Next</button>)}&nbsp;{page<formData.length-1&&(<buttononClick={()=>setPage(page-1)}>Back</button>)}</form>);};
Enter fullscreen modeExit fullscreen mode

For navigation we'll increment or decrement the page index that we're on which will trigger the effect and updatecurrentPageData, forcing a render of the new fields.

And with that, the basics of our dynamic form is done, time to ship to production!

But in Amy's case there were two more things that needed to be handled, let's start with conditional fields.

Conditional fields

It's not uncommon to have a form that when an option is set other information is required from the user. This is where conditional fields come into play, and to support them let's update our data structure a little bit:

[{"component":"page","label":"Page 1","_uid":"0c946643-5a83-4545-baea-055b27b51e8a","fields":[{"component":"field_group","label":"Name","_uid":"eb169f76-4cd9-4513-b673-87c5c7d27e02","fields":[{"component":"text","label":"First Name","type":"text","_uid":"5b9b79d2-32f2-42a1-b89f-203dfc0b6b98"},{"component":"text","label":"Last Name","type":"text","_uid":"6eff3638-80a7-4427-b07b-4c1be1c6b186"}]},{"component":"text","label":"Email","type":"email","_uid":"7f885969-f8ba-40b9-bf5d-0d57bc9c6a8d"},{"component":"text","label":"Phone","type":"text","_uid":"f61233e8-565e-43d0-9c14-7d7f220c6020"}]},{"component":"page","label":"Page 2","_uid":"3a30803f-135f-442c-ab6e-d44d7d7a5164","fields":[{"component":"options","label":"Radio Buttons","type":"radio","_uid":"bd90f44a-d479-49ae-ad66-c2c475dca66b","options":[{"component":"option","label":"Option 1","value":"one"},{"component":"option","label":"Option 2","value":"two"}]},{"component":"text","label":"Conditional Field","type":"text","_uid":"bd90f44a-d479-49ae-ad66-c2c475daa66b","conditional":{"value":"two","field":"3a30803f-135f-442c-ab6e-d44d7d7a5164_bd90f44a-d479-49ae-ad66-c2c475dca66b"}}]}]
Enter fullscreen modeExit fullscreen mode

We've added a second page and the last field on the page has a new property on it,conditional, that has two properties,value being the value that the field must have to force a display andfield is the field that should have that value, made up of theuid of the page and field.

Now we're going to have to update our rendering logic to make sure we only render the fields that should be displayed. We'll start by creating a function that returns whether a field should be rendered or not:

constfieldMeetsCondition=values=>field=>{if(field.conditional&&field.conditional.field){constsegments=field.conditional.field.split("_");constfieldId=segments[segments.length-1];returnvalues[fieldId]===field.conditional.value;}returntrue;};
Enter fullscreen modeExit fullscreen mode

ThefieldMeetsCondition function is a function that returns a function, sort of likepartial application in F#, we do this so that we can simplify how it's passed to theArray.filter before theArray.map call.

Within the function it will attempt to find the field in thevalues dictionary and match it with the required value. If no condition exists then we'll bail out and render the field.

Now we can update our render logic:

// snipreturn(<formonSubmit={onSubmit}><h2>{currentPageData.label}</h2>{currentPageData.fields.filter(fieldMeetsCondition(values)).map((field)=>{// snip
Enter fullscreen modeExit fullscreen mode

And we're conditionally showing fields based on user input. Now to conditionally show pages.

Conditional pages

The last requirement Amy had was to be able to display steps based on the user input, so that steps could be skipped if they aren't relevant. This is a little trickier than conditional fields, as we can no longer just increment the page index, we'll need to search for the appropriate page index.

Let's extract a function to work out the next/previous process:

constnavigatePages=direction=>()=>{constfindNextPage=page=>{constupcomingPageData=formData[page];if(upcomingPageData.conditional&&upcomingPageData.conditional.field){constsegments=upcomingPageData.conditional.field.split("_");constfieldId=segments[segments.length-1];constfieldToMatchValue=values[fieldId];if(fieldToMatchValue!==upcomingPageData.conditional.value){returnfindNextPage(direction==="next"?page+1:page-1);}}returnpage;};setPage(findNextPage(direction==="next"?page+1:page-1));};constnextPage=navigatePages("next");constprevPage=navigatePages("prev");
Enter fullscreen modeExit fullscreen mode

Again, we'll use a function that returns a function, but this time we'll pass in the direction of navigation,next orprev, and then it'll work out whether to+ or-, allowing us to reuse the function.

This function contains a recursive function calledfindNextPage that when the button is clicked we'll call to start our discovery process. Within that function we'll grab the next sequential page and if it doesn't have anyconditional information, we'll return the index of it. If it does have aconditional field, we'll unpack it in a similar fashion to the conditional field test and compare the required value to the user value, and if they don't match, we'll go to the next (or previous) page in the stack. We'll repeat the process again until we find a page that meets the condition or a page without a condition.

Note: There is a limitation here, if you start or end with conditional fields you can end up exceeding the index range because it doesn't check if you're hitting the edges. That is something you can tackle yourself.

Conclusion

Throughout this post we've taken a look at how we can use React to create a dynamic form, starting with what state we need to store as React Hooks, how we can handle the user input with Controlled Components and eventually implemented conditional logic for showing fields and navigating between steps.

You can check out the full sample on Codesandbox:

Top comments(3)

Subscribe
pic
Create template

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

Dismiss
CollapseExpand
 
ferhatavdic profile image
FerhatAvdic
Junior web developer
  • Joined

Great post on dynamic forms. Im curious how this would play with formik forms and yup validation.

CollapseExpand
 
aaronpowell profile image
Aaron Powell
Hi, my name’s Aaron Powell and I’m a Cloud Advocate at Microsoft. My area of specialty is front-end web dev and .NET (especially F#), but I enjoy doing silly things with technology.
  • Location
    Sydney, Australia
  • Pronouns
    he/him
  • Work
    Principal Cloud Advocate at Microsoft
  • Joined

To integrate with formik (and just to be transparent, I'm not an expert on formik) I would start with wrapping the<form> element with<Formik> and provide it with the information for current page (the fields and values), but not the whole form.

This way formik doesn't have to concern itself with the fact that it's a multi-step form or that there's conditional logic, you're handling that externally, and formik looks at the current form view.

CollapseExpand
 
ferhatavdic profile image
FerhatAvdic
Junior web developer
  • Joined

Thanks for the input man!

Id really love to create a template for dynamic forms and use it everywhere in the app. The issue is in the layout the UX team provides. It is often times very different on each page so it might be hard to include props for layout as well. Stripping the code from conditional logic and steps might work. I will probably try it out this year.

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

Hi, my name’s Aaron Powell and I’m a Cloud Advocate at Microsoft. My area of specialty is front-end web dev and .NET (especially F#), but I enjoy doing silly things with technology.
  • Location
    Sydney, Australia
  • Pronouns
    he/him
  • Work
    Principal Cloud Advocate at Microsoft
  • Joined

More fromAaron Powell

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