Project Setup and Dependencies: react hook form and TypeScript
Before we start, you should be able to follow along if you have a react project with react hook form installed. In case you don’t – please go to this post, create a react hook form, then come back. For those that have their own react hook form form ready, you can follow along.
npm install react-hook-form --save
Before moving any further with the code, we need to think how this conditional logic will work for our form. For example: if <field_A> is “YES” then <field_B> is displayed, then is <field_B> is “NO” then <field_C> is hidden and <field_D> is displayed. This is the conditional logic we have to follow to make our conditional form fields work – for example.
- Do you have experience with JavaScript?
- Years of experience with JavaScript?
- Do you know React?
- Do you know React Hook Form?
- What other language do you normally use?
- What is your name?
- What is your phone?
- Initially only question [1] will be visible.
- Then if question [1] is “YES”:
- Show question [2], and If question [2] is greater than 2 years. Â
- Show question [3], and if question [3] is “YES” show question [4]
- Otherwise If question [1] is “NO”:
- Show question [5]
- After question [1] and [2] or [3] or [4] or [5] is answered then we show question [6]
- After question [6] we then show question [7]
- Then if question [1] is “YES”:
As you can see this simple form can get quite complex quickly. We have to abstract this logic as best as we can if we want to easily maintain complex forms logic or to reuse this type of form. On top of that, there is another layer: validation rules that work with the above conditional logic, so form errors are consistent with this logic. We will not be covering validating these field on this post, to keep this post focused on conditional logic. I am going to add validation on a separate post.
Implementing Conditional Form Fields
Let’s create a new folder inside our src folder, and name it “types”. Inside our project src folder:
mkdir types && cd types && touch index.ts && cd ..
export type FormInput = {
type: 'text' | 'radio' | 'select' | 'checkbox' | 'textarea' | 'submit';
fieldType?: 'text' | 'number'
name: string
value: string | number
placeholder?: string
label?: string
options?: SelectOpt[],
depConditionsTrueIf?: 'all' | 'any'
conditionalLogic?: ConditionalLogic[]
}
export type SelectOpt = {
text: string
val: string | number
}
export type ConditionalLogic = {
depFieldName: string
depFieldValue: string | number
depConditionsTrueIf?: 'any' | 'all',
depFieldValueCondition: '>' | '<' | '>=' | '<=' | '=' | '!=' | 'NotEmpty'
andGroup?: ConditionalLogic[]
}
depConditionsTrueIf: this will help us consider a condition true only if all conditions are true or any condition is true. This is like a programatic AND or OR condition.
conditionalLogic: this is the core of our conditional fields. This is an array of conditions that need to be satisfied in order for this field to be displayed. In case of depConditionsTrueIf is set to all then all conditions need to be true to show this form field, otherwise setting it to any will show this field if any condition is true.
Writing our Input Fields Form Schema
Let’s first create a folder for our form inside our src folder, and create a file for it:
mkdir forms && cd forms && touch forms.ts && cd ..
import type { FormInput } from "../types";
export const surveyForm: FormInput[] = [
{
type: 'radio',
options: [{ text: 'Yes', val: 'yes'}, { text: 'No', val: 'no'} ],
name: 'has-JS-Experience',
value: '',
label: 'Do you have experience with JavaScript?',
},
{
type: 'text',
fieldType: 'number',
name: 'js-years-experience',
value: '',
label: 'Years of experience with JavaScript?',
conditionalLogic: [
{
depFieldName: 'has-JS-Experience',
depFieldValue: 'yes',
depFieldValueCondition: '='
},
]
},
{
type: 'radio',
options: [{ text: 'Yes', val: 'yes'}, { text: 'No', val: 'no'} ],
name: 'know-react',
value: '',
label: 'Do you know React?',
conditionalLogic: [
{
depFieldName: 'js-years-experience',
depFieldValue: 2,
depFieldValueCondition: '>'
},
]
},
{
type: 'radio',
options: [{ text: 'Yes', val: 'yes'}, { text: 'No', val: 'no'} ],
name: 'know-react-hook-form',
value: '',
label: 'Do you know React Hook Form?',
conditionalLogic: [
{
depFieldName: 'know-react',
depFieldValue: 'yes',
depFieldValueCondition: '='
},
]
},
{
type: 'text',
name: 'what-other-language',
value: '',
placeholder: 'Node.js, Go, Python',
label: 'What other language do you know?',
conditionalLogic: [
{
depFieldName: 'has-JS-Experience',
depFieldValue: 'no',
depFieldValueCondition: '='
},
]
},
{
type: 'text',
name: 'what-is-your-name',
value: '',
label: 'What is your name?',
depConditionsTrueIf: 'any',
conditionalLogic: [
{
depFieldName: 'has-JS-Experience',
depFieldValue: 'yes',
depFieldValueCondition: '=',
andGroup: [
{
depFieldName: 'js-years-experience',
depFieldValue: '',
depFieldValueCondition: 'NotEmpty'
},
{
depFieldName: 'know-react',
depFieldValue: '',
depFieldValueCondition: 'NotEmpty'
},
{
depFieldName: 'know-react-hook-form',
depFieldValue: '',
depFieldValueCondition: 'NotEmpty'
},
]
},
{
depFieldName: 'has-JS-Experience',
depFieldValue: 'no',
depFieldValueCondition: '=',
andGroup: [
{
depFieldName: 'what-other-language',
depFieldValue: '',
depFieldValueCondition: 'NotEmpty'
}
]
}
]
},
{
type: 'text',
name: 'what-is-your-phone',
value: '',
label: 'What is Your Phone?',
conditionalLogic: [
{
depFieldName: 'what-is-your-name',
depFieldValue: '',
depFieldValueCondition: 'NotEmpty'
},
]
},
{
type: 'submit',
name: 'submit-dev-survey',
value: '',
label: 'Submit Survey',
depConditionsTrueIf: 'any',
conditionalLogic: [
{
depFieldName: 'know-react',
depFieldValue: 'no',
depFieldValueCondition: '='
},
{
depFieldName: 'what-is-your-phone',
depFieldValue: '',
depFieldValueCondition: 'NotEmpty'
}
]
},
]
Perfect! what a long file you may be saying, but we have created our entire form and defined our conditional logic. As you may have noticed we have a type that maps to the type of form field we want, next we have a name that goes into the name property of the form field and we have a label that is the descriptive text and a value that is the initial value for each field. These are the simplest properties to understand.
Next we have a conditionalLogic that is an array of objects that follows our ConditionalLogic type we created in our types/index.ts TypeScript file. It works as follows:
- depFieldName: the name of the current field depends on
- depFieldValue: the field value that we are looking for this condition to be valid.
- depFieldValueCondition: the condition we are using to compare the other field’s value
- andGroup: an optional property to chain additional conditions that should be true in order to consider this conditional logic valid (so our form field can be shown)
Question 1: (Have JS experience?) Show all the time (notice there is no conditionalLogic property)
Question 2: (JS Years of Experience?) Show only if [Question 1] (with field name: has-JS-experience) is equal to “yes”
Question 3: (Know React?) Show only if [Question 2] (JS-years-experience) is greater than 2
Question 4: (Know react hook form?) Show only if [Question 3] (know-react) is equal to “yes”
Question 5: (What other language?) Show only if [Question 1] is equal to “no”
Question 6: (What is your name?) Show only if any of the following are true:
  – [Question 1] (has-JS-Experience) is equal to “yes” AND:
     – [Question 2] (JS-years-experience) is not equal to empty string
     – [Question 3] (know-react) is not equal to empty string
     – [Question 4] (know-react-hook-form) is not equal to empty string
  – [Question 1] (has-JS-Experience) is equal to “no” AND:
     – [Question 5] is not empty.
Question 7: (What is your Phone?) Show only if [Question 6] (what-is-your-name) is not equal to empty string.
Building a Form Generator with Conditional Fields and React Hook Form
import type { ConditionalLogic } from "../types";
export const passesConditionalLogic = (rootFieldName: string, conditionalLogic: ConditionalLogic[] | undefined, logicTrueIf: 'all' | 'any' | undefined, watchList: Record<string, any>): boolean => {
if (!conditionalLogic) {
return true;
}
let andGroupMet = true;
let res = true;
let runningCond = true;
const conditionType = logicTrueIf === 'any' ? 'any' : 'all';
for (let i = 0; i < conditionalLogic.length; i += 1) {
const condLogic = conditionalLogic[i];
const fieldName = condLogic.depFieldName;
const condOp = condLogic.depFieldValueCondition;
const fieldValue = watchList[fieldName];
const expectedValue = condLogic.depFieldValue;
const andGroup = condLogic?.andGroup;
// reset on each new condition, as we only need any condition to be true!
if (conditionType === 'any') {
andGroupMet = true;
}
res = evalConditionalLogic(fieldValue, condOp, expectedValue);
if (andGroup) {
// only set runningCond initially when there is a andGroup
if (i === 0) {
runningCond = false;
}
for (let andGroupLogic of andGroup) {
const andFieldName = andGroupLogic.depFieldName;
const andCondOp = andGroupLogic.depFieldValueCondition;
const andFieldValue = watchList[andFieldName];
const andExpectedValue = andGroupLogic.depFieldValue;
andGroupMet = andGroupMet && evalConditionalLogic(andFieldValue, andCondOp, andExpectedValue);
if (!andGroupMet) {
break;
}
}
}
if (conditionType === 'all') {
runningCond = res && runningCond;
} else if (conditionType === 'any') {
if (andGroup) {
runningCond = runningCond || (andGroupMet && res);
} else {
runningCond = res;
}
// if true, we found a condition that shows this field, no need to keep checking
if (runningCond) {
break;
}
}
}
return runningCond;
}
const evalConditionalLogic = (value: string, condOp: string, expectedValue: string | number): boolean => {
let res;
switch (condOp) {
case 'NotEmpty':
res = value?.length > 0;
break;
case '!=':
res = value !== expectedValue;
break;
case '<':
res = value < expectedValue;
break;
case '<=':
res = value <= expectedValue;
break;
case '=':
res = value === expectedValue;
break;
case '>':
res = value > expectedValue;
break;
default:
res = false;
}
return res;
}
– Lines 12 to 58 iterate over all the conditions inside our conditionalLogic array. We extract the configuration parameters of this condition and check if we have a conditionType set to “any” – if so we reset our accumulated boolean evaluator called andGroupMet (after processing 1 or more conditions) so we can continue to chain the previous value with new values.
– Lines 45 to 58 we have a condition checking if we have an “all” or “any” condition and merges the previous boolean result depending on each case: if “all” then we merge the current result with the previous result. If “any” and we have an “andGroup” on that field: we merge the running boolean OR the evaluation of the andGroupMet with the result variable of the parent field evaluation. For conditional fields with no andGroup we only store the result in our running condition (runningCond variable on line 51)
– Lines 62 to 87 we simply process each comparison according to the conditional operation we have as a string value in our json form definition. The evaluation is done and then returned to the caller of the function. Simple. Having this on a separate function helps us extend it with additional comparison operators.
Implementing a Form Generator Component with Dynamic Fields
The final part is reading from our json form definition: what fields we need and creating each according to its type.
For that we are going to create our dependencies or component that will generate the the form input control. In other words, each input field will be an independent React component.
First let’s create a new file called (while still inside our ./forms folder): DynamicInputControl.tsxÂ
touch DynamicInputControl.tsx
import {
Control,
FieldErrors,
UseFormRegister,
UseFormUnregister,
useWatch,
} from "react-hook-form";
import { ConditionalLogic, SelectOpt } from "../types";
import { useEffect } from "react";
import { passesConditionalLogic } from "./utils";
Great, now let’s define the types for our component props.Â
type DynamicInputControlProps = {
name:string;
label:string|undefined;
type:string;
placeholder:string|undefined;
fieldType:string|undefined;
options:SelectOpt[] |undefined;
conditionalLogic:ConditionalLogic[] |undefined;
register:UseFormRegister<any>;
unregister:UseFormUnregister<any>;
logicTrueIf?:"all"|"any";
errors:FieldErrors;
control:Control;
index:number;
};
import {
Control,
FieldErrors,
UseFormRegister,
UseFormUnregister,
useWatch,
} from "react-hook-form";
import { ConditionalLogic, SelectOpt } from "../types";
import { useEffect } from "react";
import { passesConditionalLogic } from "./utils";
type DynamicInputControlProps = {
name: string;
label: string | undefined;
type: string;
placeholder: string | undefined;
fieldType: string | undefined;
options: SelectOpt[] | undefined;
conditionalLogic: ConditionalLogic[] | undefined;
register: UseFormRegister<any>;
unregister: UseFormUnregister<any>;
logicTrueIf?: "all" | "any";
errors: FieldErrors;
control: Control;
index: number;
};
const DynamicInputControl = ({
name,
label,
type,
fieldType,
placeholder,
options,
conditionalLogic,
register,
unregister,
logicTrueIf,
errors,
control,
}: DynamicInputControlProps) => {
const error = (errors[name]?.message as string) || null;
const watchList = useWatch({
control,
});
const passes = passesConditionalLogic(
name,
conditionalLogic,
logicTrueIf,
watchList
);
useEffect(() => {
if (!passes) {
unregister(name);
}
}, [passes, unregister, name]);
if (!passes) {
return null;
}
switch (type) {
case "text":
return (
<div key={name} className={`field ${error ? "has-error" : ""}`}>
<label htmlFor={name}>{label}</label>
<input
placeholder={placeholder || ""}
id={name}
{...register(name)}
type={fieldType ? fieldType : "text"}
/>
<span>{error}</span>
</div>
);
case "radio":
return (
<div key={name} className={`field ${error ? "has-error" : ""}`}>
<label>{label}</label>
{options?.map(({ text, val }) => (
<span key={`${text}-${val}`}>
{text}
<input {...register(name)} type="radio" value={val} />
<span>{error}</span>
</span>
))}
</div>
);
case "select":
return (
<div key={name} className={`field ${error ? "has-error" : ""}`}>
<label htmlFor={name}>{label}</label>
<select name={name}>
{options?.map(({ text, val }) => (
<option key={val}>{text}</option>
))}
</select>
<span>{error}</span>
</div>
);
case "submit":
return (
<div key={name} className={`field`}>
<input type="submit" value={label} />
</div>
);
}
return null;
};
export default DynamicInputControl;
We pass the control property inside as saying: “we want to listen to the notifications that belong to the main form we are creating” – this means that all our form input controls are wired together with this control property. This is part of react hook form’s API and we are making sure we are following closely according to the react hook form docs.Â
Some might say: “but why listen to all other form fields?, and why not instead listen to the fields each form field depends on?” – good question – the answer is: in the case our user fills several of our questions and decides to go back to our first question – for example – (Do you have experience with JS?) and changes the answer from “no” to “yes” or viceversa, would cause some conditions not to re-render (or recheck or conditional logic when it is needed)
The next interesting line is line 47: we imported our function passesConditionalLogic which calls our field conditional logic evaluator that takes the name of the field, the conditional logic array (defined in our form json definition), what type of evaluation it is (all or any) and finally the values of all our fields that comes from the call to useWatch – the react hook that monitors the values of our form made with react hook form.
This function will give us if the conditional logic passed or not. In the case that our form field’s conditional logic fails (all the relevant conditions are not met) then we simply render null on line 61 (null is “nothing” in React) and we also unregister the form field on line 56 (meaning removing the data and field registration inside react hook form.
Lines 64 to 109 define each type of form field, and return each depending on the type of form field we want to show our user. This is simply handled by a switch statement checking the type, and then adding the name property, a placeholder (in case we have one). Each is wrapped inside an outer div and calls the register function to tell reach hook form that we are associating this form field to our form.
Creating a Form Generator Wrapper Component
To finish our project we have to make our form generator component that will import our DynamicInputControl component.
Let’s show the code and then explain what it does:
import { useMemo } from "react";
import type { FormInput } from "../types";
import { useForm, Resolver, SubmitHandler } from "react-hook-form";
import DynamicInputControl from "./DynamicInputControl";
type FormGeneratorProps = {
schema: FormInput[];
resolver?: Resolver;
};
const FormGenerator = ({ schema, resolver }: FormGeneratorProps) => {
type DynamicForm = {
[K in (typeof schema)[number]["name"]]: (typeof schema)[number]["name"];
};
const defaultValues = useMemo(() => {
return schema.reduce((acc: Record<string, string | number>, i) => {
acc[i.name] =
typeof i.value === "boolean" ? (i.value ? "true" : "false") : i.value;
return acc;
}, {});
}, [schema]);
const {
register,
unregister,
handleSubmit,
control,
formState: { errors },
} = useForm({
resolver,
defaultValues,
});
const onFormSubmit: SubmitHandler<DynamicForm> = (
data: DynamicForm,
e?: React.BaseSyntheticEvent
) => {
e?.preventDefault();
console.log(data);
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
{schema.map((field, i) => (
<DynamicInputControl
key={field.name + "-" + i}
name={field.name}
label={field.label}
type={field.type}
placeholder={field.placeholder}
fieldType={field.fieldType}
options={field?.options}
register={register}
unregister={unregister}
logicTrueIf={field?.depConditionsTrueIf}
conditionalLogic={field?.conditionalLogic}
control={control}
errors={errors}
index={i}
/>
))}
</form>
);
};
export default FormGenerator;
Next we capture all our default values inside a useMemo which creates a cached processed copy merging all the fields from an array into an object and isolating boolean values to be “true” or “false” strings instead of actual true or false. This useMemo will only be called again when our dependency changes (line 22: schema)
On lines 24 to 33 we call the useForm hook from react hook form. This is what we use to initialize our react hook form and pass all the default values we captured above. This is where we extract our control variable to connect all our form input controls to this form. Same for the register and unregister functions that help us register or unregister form fields dynamically as our user types on our form.
On line 35 we defined our form submit handler that will capture all submit button clicks and the data the user typed on our form. We are using TypeScript generics here to tell TypeScript the type of data we are using in our form <DynamicForm>. We are also preventing our page from reloading when this button is clicked (e?.preventDefault()) and simply displaying our data in the browser’s console.
Lastly on line 43 we return by rendering our form with all the DynamicInputControl components (an array of components) that represents our form fields, and passing all the properties from our form definition json.
Conclusion
In this post we took a deep dive into the creating a dynamic input form with conditional rendering. We used the react hook form’s useWatch function to monitor what our user typed and implemented a form component using the amazing react hook form library.
We first defined our form schema or definition of the fields of our form. We then created a dynamic input control that creates each type of field, subscribed each field using the useWatch hook, and monitored if the conditional logic is valid or not. If it is not valid, we remove the field (unregister) and finally wrapped our dynamic form field inside a FormGenerator component that receives the on submit event (when our user clicks the submit button).
With this we created a dynamic react form we can use time and time again without having to maintain conditional logic by hand directly in code. As we saw on this post, complex forms can easily be challenging to create but if we go beyond and aim to reuse, we can save ourselves many hours of work as requirements change. Sure, there is a tradeoff – having to code this type of form takes more time, but with the help of this post you won’t have to spend too much time.