Building Reusable Form Components

Forms in the MERN Blog share a lot of repeated structure — a label above an input, an error message below it, a consistent visual style. Without abstraction, you copy and paste this structure dozens of times across login, register, create post, edit post, and profile forms. Reusable form components eliminate this repetition and give every form a consistent look and behaviour. In this lesson you will build three components — FormField (label + input + error), TagInput (array-of-strings input), and SubmitButton (button with loading state) — that you will use in every form for the rest of the series.

The FormField Component

// src/components/ui/FormField.jsx
import PropTypes from 'prop-types';

function FormField({
  label,
  name,
  type       = 'text',
  value,
  onChange,
  error      = '',
  required   = false,
  disabled   = false,
  placeholder = '',
  hint       = '',
  children,   // optional: renders instead of the default input
}) {
  const inputId = `field-${name}`;
  const hasError = Boolean(error);

  return (
    <div className={`form-group${hasError ? ' form-group--error' : ''}`}>
      {/* Label */}
      <label htmlFor={inputId} className="form-label">
        {label}
        {required && <span className="form-label__required" aria-hidden> *</span>}
      </label>

      {/* Custom input (e.g. textarea, TagInput) or default input */}
      {children ? (
        children
      ) : (
        <input
          id={inputId}
          name={name}
          type={type}
          value={value}
          onChange={onChange}
          disabled={disabled}
          placeholder={placeholder}
          aria-invalid={hasError}
          aria-describedby={hasError ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
          className={`input${hasError ? ' input--error' : ''}`}
        />
      )}

      {/* Hint text — shown when there is no error */}
      {hint && !hasError && (
        <p id={`${inputId}-hint`} className="form-hint">{hint}</p>
      )}

      {/* Error message */}
      {hasError && (
        <p id={`${inputId}-error`} className="field-error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

FormField.propTypes = {
  label:       PropTypes.string.isRequired,
  name:        PropTypes.string.isRequired,
  type:        PropTypes.string,
  value:       PropTypes.string,
  onChange:    PropTypes.func,
  error:       PropTypes.string,
  required:    PropTypes.bool,
  disabled:    PropTypes.bool,
  placeholder: PropTypes.string,
  hint:        PropTypes.string,
  children:    PropTypes.node,
};

export default FormField;

// ── Usage ──────────────────────────────────────────────────────────────────────
<FormField
  label="Email address"
  name="email"
  type="email"
  value={formData.email}
  onChange={handleChange}
  error={errors.email}
  required
  hint="We will never share your email"
  placeholder="you@example.com"
  disabled={loading}
/>
Note: The FormField component uses the children prop to support custom input types like textarea and TagInput. When children are passed, FormField renders them instead of the default <input> — but still wraps them in the label and error message structure. This makes FormField flexible enough to wrap any input type while maintaining consistent styling and error display.
Tip: Add ARIA attributes to your form components for accessibility: aria-invalid={hasError} on the input so screen readers announce invalid fields, and aria-describedby pointing to the error message element so screen readers read the error when the field is focused. The role="alert" on the error paragraph causes screen readers to announce it immediately when it appears.
Warning: Avoid making FormField too generic. A component that handles every possible input configuration through props becomes harder to use than just writing the HTML directly. Keep FormField focused on the most common pattern (text/email/password inputs with a label and optional error). For highly custom inputs — date pickers, rich text editors, file dropzones — build dedicated components rather than extending FormField with more and more special-case props.

The TagInput Component

// src/components/ui/TagInput.jsx
import { useState } from 'react';
import PropTypes    from 'prop-types';
import Badge        from './Badge';

function TagInput({ tags = [], onChange, disabled = false, maxTags = 10 }) {
  const [inputValue, setInputValue] = useState('');

  const addTag = () => {
    const tag = inputValue.trim().toLowerCase().replace(/\s+/g, '-');
    if (!tag)                          return; // empty
    if (tags.includes(tag))            return; // duplicate
    if (tags.length >= maxTags)        return; // limit reached
    onChange([...tags, tag]);                   // update parent state
    setInputValue('');                          // clear input
  };

  const removeTag = (tagToRemove) => {
    onChange(tags.filter(t => t !== tagToRemove));
  };

  const handleKeyDown = (e) => {
    if (e.key === 'Enter' || e.key === ',') {
      e.preventDefault(); // prevent form submission on Enter
      addTag();
    }
    if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
      // Remove last tag when backspace is pressed in empty input
      removeTag(tags[tags.length - 1]);
    }
  };

  return (
    <div className="tag-input">
      {/* Existing tags as removable chips */}
      <div className="tag-input__chips">
        {tags.map(tag => (
          <span key={tag} className="tag-chip">
            <Badge label={`#${tag}`} variant="tag" />
            {!disabled && (
              <button
                type="button"
                onClick={() => removeTag(tag)}
                className="tag-chip__remove"
                aria-label={`Remove tag ${tag}`}
              >
                ×
              </button>
            )}
          </span>
        ))}
      </div>

      {/* New tag input */}
      {!disabled && tags.length < maxTags && (
        <div className="tag-input__entry">
          <input
            type="text"
            value={inputValue}
            onChange={e => setInputValue(e.target.value)}
            onKeyDown={handleKeyDown}
            onBlur={addTag} // add tag when user clicks away
            className="input input--sm"
            placeholder={`Add a tag (${tags.length}/${maxTags})...`}
          />
          <button type="button" onClick={addTag} className="btn btn--sm">
            Add
          </button>
        </div>
      )}

      {tags.length >= maxTags && (
        <p className="form-hint">Maximum {maxTags} tags reached.</p>
      )}
    </div>
  );
}

TagInput.propTypes = {
  tags:     PropTypes.arrayOf(PropTypes.string),
  onChange: PropTypes.func.isRequired,
  disabled: PropTypes.bool,
  maxTags:  PropTypes.number,
};

export default TagInput;

The SubmitButton Component

// src/components/ui/SubmitButton.jsx
import PropTypes from 'prop-types';
import Spinner   from './Spinner';

function SubmitButton({
  label         = 'Submit',
  loadingLabel  = 'Submitting...',
  loading       = false,
  disabled      = false,
  variant       = 'primary',
  fullWidth     = false,
}) {
  return (
    <button
      type="submit"
      className={`btn btn--${variant}${fullWidth ? ' btn--full' : ''}`}
      disabled={loading || disabled}
      aria-busy={loading}
    >
      {loading ? (
        <>
          <Spinner size="small" message="" />
          {loadingLabel}
        </>
      ) : (
        label
      )}
    </button>
  );
}

SubmitButton.propTypes = {
  label:        PropTypes.string,
  loadingLabel: PropTypes.string,
  loading:      PropTypes.bool,
  disabled:     PropTypes.bool,
  variant:      PropTypes.oneOf(['primary', 'secondary', 'danger']),
  fullWidth:    PropTypes.bool,
};

export default SubmitButton;

// ── Usage ──────────────────────────────────────────────────────────────────────
<SubmitButton
  label="Log In"
  loadingLabel="Logging in..."
  loading={loading}
  fullWidth
/>

Refactoring the Login Form with Reusable Components

// LoginPage.jsx — after refactoring with FormField and SubmitButton
import FormField    from '@/components/ui/FormField';
import SubmitButton from '@/components/ui/SubmitButton';

return (
  <form onSubmit={handleSubmit} noValidate>
    {apiError && <div className="alert alert--error">{apiError}</div>}

    <FormField
      label="Email address" name="email" type="email"
      value={formData.email}   onChange={handleChange}
      error={errors.email}     required disabled={loading}
      placeholder="you@example.com"
    />

    <FormField
      label="Password"     name="password" type="password"
      value={formData.password} onChange={handleChange}
      error={errors.password}   required disabled={loading}
    />

    <SubmitButton label="Log In" loadingLabel="Logging in..." loading={loading} fullWidth />
  </form>
);
// Compared to the raw version in Lesson 2 — much less repetition ✓

Common Mistakes

Mistake 1 — Making FormField too opinionated about layout

❌ Wrong — hard-coding a two-column layout inside FormField:

// FormField renders a grid — breaks on any form that needs a single column
<div className="two-column-grid"><label /><input /></div>

✅ Correct — FormField only controls the label/input/error structure; layout is controlled by the parent form’s CSS class on the container.

Mistake 2 — TagInput submitting the form on Enter

❌ Wrong — pressing Enter in the tag input submits the form:

const handleKeyDown = (e) => {
  if (e.key === 'Enter') addTag(); // missing e.preventDefault()
  // Form's onSubmit fires too!
};

✅ Correct — prevent default on Enter inside TagInput:

if (e.key === 'Enter') { e.preventDefault(); addTag(); } // ✓

Mistake 3 — Not normalising tag input (case, spaces)

❌ Wrong — storing tags with inconsistent formatting:

onChange([...tags, inputValue]); // "MERN", "Mern", "mern" stored as different tags

✅ Correct — normalise before storing:

const tag = inputValue.trim().toLowerCase().replace(/\s+/g, '-');
onChange([...tags, tag]); // always lowercase-hyphenated ✓

Quick Reference

Component Key Props Use For
FormField label, name, type, value, onChange, error, required Any text/email/password/url input with label + error
FormField + children label, name, error + children slot Textarea, TagInput, or any custom input in a labelled wrapper
TagInput tags, onChange, disabled, maxTags Array of string tags with add/remove chips
SubmitButton label, loadingLabel, loading, disabled, fullWidth Form submit button with loading spinner

🧠 Test Yourself

When the user presses Enter inside the TagInput’s text field, the entire Create Post form submits instead of adding the tag. What is the cause and fix?