How to Start Thinking About Any Problem

kietHTkietHT
6 min read

Step 1: Understanding the Problem

Before jumping into code, let's break down the problem logically:

  1. 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.

  2. 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.

  3. 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.

  4. 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:

  1. Rendering the form fields: Loop through formConfig and render different input types based on each item’s type.

  2. 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.

  3. Handling errors: For each field that fails validation, store the error message and highlight the field.

  4. 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:

  1. The form loads with an empty state.

  2. The user fills in the fields and sees validation errors in real-time.

  3. Once all fields are valid, the form can be submitted.

  4. 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.

0
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.