How to Start Thinking About Any Problem
Step 1: Understanding the Problem
Before jumping into code, let's break down the problem logically:
Dynamic Form Fields: The form needs to render different types of fields based on some configuration object (this could be fetched from an API, loaded from a file, or manually defined).
You need to handle different input types (text, number, date, and select).
Dynamic means that fields can be added or removed based on user interactions, so it’s not a static form.
Validation Rules: Each field can have its own validation rules. We need to figure out how to apply those rules efficiently.
Required Fields: A field could be required, meaning it can’t be empty.
Pattern Matching: Some fields need to match a regular expression (like email or phone number validation).
Range Validation: For number and date fields, we might have minimum and maximum values.
Error Feedback: The form should provide clear error messages for invalid fields.
You need to validate the fields as the user types or when they submit the form.
Once validation fails, you should highlight the field and show an error message.
Form Submission: After validation, the form should submit, and you should handle the validated data accordingly (send it to the server, log it, etc.).
Step 2: Decide How to Store the Form Configuration and Data
Now that we know what the problem is, let’s think about the data structure we need to support dynamic fields and validation.
For simplicity, let’s assume the form configuration might look like this:
const formConfig = [
{ id: 'username', type: 'text', label: 'Username', required: true, pattern: /^[a-zA-Z0-9_]{3,20}$/ },
{ id: 'email', type: 'text', label: 'Email', required: true, pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/ },
{ id: 'age', type: 'number', label: 'Age', min: 18, max: 99 },
{ id: 'birthdate', type: 'date', label: 'Birthdate', required: true },
{ id: 'country', type: 'select', label: 'Country', options: ['USA', 'Canada', 'UK'], required: true },
];
Each object defines:
The field
id
, which we’ll use to access it.The
type
, which defines the HTML input type.label
for display.required
flag to enforce validation.Pattern, min/max, options for select-type fields.
You’d store user input data and validate it using these configurations.
Step 3: Break the Problem Into Smaller Pieces
Before going into the code, break this problem into manageable chunks:
Rendering the form fields: Loop through
formConfig
and render different input types based on each item’stype
.Validation function: Write a generic validation function that takes a field’s rules (required, pattern, min/max) and checks if the user input is valid.
Handling errors: For each field that fails validation, store the error message and highlight the field.
Form submission: Once all fields are validated, gather the data and submit it.
Step 4: State Management
Since we have a dynamic form, we’ll need state to store the form values, validation states, and error messages. You’ll need to track:
The current value of each field.
Validation errors for each field.
This is a perfect use case for React state (local component state or even a global state if the form is complex). But, keep in mind: this is all about performance and organization. Don’t overcomplicate it!
Step 5: Think About User Interaction
Users will be typing or selecting options. Consider debouncing or validating inputs as they type to prevent excessive validation calls. Highlight invalid fields clearly. Don’t forget to handle the submit event and give users feedback!
Step 6: Consider Optimizations
For large forms, avoid re-rendering the entire form whenever one field changes. Use
React.memo
or similar techniques for optimization.For complex validation (like regex or min/max), debounce the input checks to avoid firing validation for every keystroke.
Step 7: Handle Edge Cases
What if the user enters invalid input after submission? How should the UI respond?
What if the user dynamically adds fields to the form after starting the submission process? How do you handle that gracefully?
Step 8: Sketch a UI Flow
Think about the user flow:
The form loads with an empty state.
The user fills in the fields and sees validation errors in real-time.
Once all fields are valid, the form can be submitted.
On submit, send the data to the server or log it.
Step 1: Define Your Form Configuration
Let’s define the form configuration that’ll hold all the fields, their types, and validation rules. This is the “data structure” we were talking about:
const formConfig = [
{ id: 'username', type: 'text', label: 'Username', required: true, pattern: /^[a-zA-Z0-9_]{3,20}$/ },
{ id: 'email', type: 'text', label: 'Email', required: true, pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/ },
{ id: 'age', type: 'number', label: 'Age', min: 18, max: 99 },
{ id: 'birthdate', type: 'date', label: 'Birthdate', required: true },
{ id: 'country', type: 'select', label: 'Country', options: ['USA', 'Canada', 'UK'], required: true },
];
Step 2: Set Up State for Form Data and Validation
You need to store the user input and track validation errors. We’ll set up state for both.
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
The formData state will hold the current values of all the fields, and errors will hold any validation messages for the fields.
Step 3: Render the Form Dynamically
We need to map over the formConfig
and render the corresponding input field based on the type
. This is where things get interesting because we’re rendering text fields, date pickers, dropdowns, etc.
return (
<form onSubmit={handleSubmit}>
{formConfig.map(field => {
switch (field.type) {
case 'text':
case 'number':
case 'date':
return (
<div key={field.id}>
<label htmlFor={field.id}>{field.label}</label>
<input
type={field.type}
id={field.id}
value={formData[field.id] || ''}
onChange={handleInputChange}
/>
{errors[field.id] && <p className="error">{errors[field.id]}</p>}
</div>
);
case 'select':
return (
<div key={field.id}>
<label htmlFor={field.id}>{field.label}</label>
<select
id={field.id}
value={formData[field.id] || ''}
onChange={handleInputChange}
>
{field.options.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
{errors[field.id] && <p className="error">{errors[field.id]}</p>}
</div>
);
default:
return null;
}
})}
<button type="submit">Submit</button>
</form>
);
Step 4: Handle Input Changes
Whenever the user types or selects something, we need to update the formData state and trigger validation.
const handleInputChange = (e) => {
const { id, value } = e.target;
setFormData(prev => ({
...prev,
[id]: value
}));
// Validate the field every time it changes
validateField(id, value);
};
Step 5: Validate Each Field
Now, for the juicy part. Validation needs to happen every time the user interacts with a field. For each field, we check if it’s required, matches the pattern, and if the min/max values are valid.
const validateField = (id, value) => {
const field = formConfig.find(f => f.id === id);
let errorMessage = '';
if (field.required && !value) {
errorMessage = `${field.label} is required`;
} else if (field.pattern && !field.pattern.test(value)) {
errorMessage = `Invalid ${field.label}`;
} else if (field.min !== undefined && value < field.min) {
errorMessage = `${field.label} must be at least ${field.min}`;
} else if (field.max !== undefined && value > field.max) {
errorMessage = `${field.label} must not exceed ${field.max}`;
}
setErrors(prev => ({
...prev,
[id]: errorMessage
}));
};
Step 6: Handle Form Submission
When the user submits the form, we first validate all the fields and then submit the data if everything is valid.
const handleSubmit = (e) => {
e.preventDefault();
// Validate all fields before submitting
formConfig.forEach(field => {
validateField(field.id, formData[field.id]);
});
// If there are no errors, submit the form data
if (Object.values(errors).every(error => !error)) {
console.log('Form submitted:', formData);
// Here you could send the form data to an API, etc.
}
};
Step 7: Add Styling for Error Feedback
Finally, you’ll want to provide clear visual feedback for validation errors. Maybe something like:
code.error {
color: red;
font-size: 12px;
}
Step 8: Final Touch
You could enhance this form by adding things like:
Debouncing for real-time validation.
Dynamic error messages (e.g., showing "Please enter a valid email" instead of just "Invalid Email").
A loading spinner when the form is submitting.
Subscribe to my newsletter
Read articles from kietHT directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
kietHT
kietHT
I am a developer who is highly interested in TypeScript. My tech stack has been full-stack TS such as Angular, React with TypeScript and NodeJS.