Reusable Form Components — Input, Select and FormField

Repeating the same input + label + error-message structure in every form creates maintenance overhead. A FormField wrapper component eliminates this repetition — it renders the label, the input (via children), and the error message in a consistent layout. A reusable Input component standardises styling and uses forwardRef so parent components can access the DOM node for focus management. The TagInput component manages a list of string values with add-on-Enter and remove-on-click behaviour.

FormField Wrapper Component

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

export function FormField({ label, htmlFor, error, required = false, children }) {
    return (
        <div className="space-y-1">
            {label && (
                <label
                    htmlFor={htmlFor}
                    className="block text-sm font-medium text-gray-700"
                >
                    {label}
                    {required && <span className="text-red-500 ml-1">*</span>}
                </label>
            )}
            {children}
            {error && (
                <p className="text-red-500 text-sm" role="alert">
                    {error}
                </p>
            )}
        </div>
    );
}

FormField.propTypes = {
    label:    PropTypes.string,
    htmlFor:  PropTypes.string,
    error:    PropTypes.string,
    required: PropTypes.bool,
    children: PropTypes.node.isRequired,
};

// Usage:
// <FormField label="Title" htmlFor="title" error={errors.title} required>
//   <Input id="title" name="title" value={form.title} onChange={handleChange} />
// </FormField>
Note: The role="alert" on the error paragraph makes screen readers announce the error message immediately when it appears — even without the user navigating to the element. This is important for keyboard-only and screen-reader users who may not see the visual red text. For accessibility, also set aria-invalid="true" on the input when there is an error and aria-describedby pointing to the error message’s ID.
Tip: Use React’s forwardRef for reusable input components so parent components can call inputRef.current.focus() for programmatic focus management — moving focus to the first invalid field on form submit, or focusing the search input when a modal opens. Without forwardRef, the ref stops at the React component boundary and cannot reach the underlying DOM input element.
Warning: When spreading props onto HTML elements (<input {...rest} />), be careful not to pass React-specific or unknown props to the DOM. Props like isInvalid or errorMessage are fine on a React component but will cause React to log a warning if passed to a native HTML element. Destructure these props out before spreading: function Input({ isInvalid, errorMessage, ...rest }) { return <input className={isInvalid ? "border-red-500" : ""} {...rest} />; }

Reusable Input Component with forwardRef

// src/components/ui/Input.jsx
import { forwardRef } from "react";
import PropTypes from "prop-types";

const Input = forwardRef(function Input(
    { className = "", isInvalid = false, ...rest },
    ref
) {
    return (
        <input
            ref={ref}
            className={`w-full border rounded-lg px-3 py-2
                        focus:outline-none focus:ring-2 focus:ring-blue-500
                        ${isInvalid
                            ? "border-red-500 focus:ring-red-400"
                            : "border-gray-300"
                        }
                        ${className}`}
            aria-invalid={isInvalid || undefined}
            {...rest}
        />
    );
});

Input.displayName = "Input";

Input.propTypes = {
    isInvalid: PropTypes.bool,
    className: PropTypes.string,
};

export default Input;

TagInput Component

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

export function TagInput({ value = [], onChange, placeholder = "Add a tag..." }) {
    const [inputValue, setInputValue] = useState("");

    function addTag(tag) {
        const trimmed = tag.trim().toLowerCase();
        if (!trimmed || value.includes(trimmed)) return;
        onChange([...value, trimmed]);
        setInputValue("");
    }

    function removeTag(tagToRemove) {
        onChange(value.filter((t) => t !== tagToRemove));
    }

    function handleKeyDown(e) {
        if (e.key === "Enter" || e.key === ",") {
            e.preventDefault();   // prevent form submit on Enter
            addTag(inputValue);
        }
        if (e.key === "Backspace" && !inputValue && value.length > 0) {
            removeTag(value[value.length - 1]);   // remove last tag on backspace
        }
    }

    return (
        <div className="flex flex-wrap gap-1 border rounded-lg px-2 py-1.5 min-h-10 cursor-text"
             onClick={() => document.getElementById("tag-input-field")?.focus()}>
            {value.map((tag) => (
                <span key={tag} className="flex items-center gap-1 bg-blue-100 text-blue-800
                                           text-sm px-2 py-0.5 rounded-full">
                    {tag}
                    <button
                        type="button"
                        onClick={() => removeTag(tag)}
                        className="hover:text-blue-900 font-bold"
                        aria-label={`Remove ${tag}`}
                    >
                        ×
                    </button>
                </span>
            ))}
            <input
                id="tag-input-field"
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
                onKeyDown={handleKeyDown}
                onBlur={() => inputValue && addTag(inputValue)}
                placeholder={value.length === 0 ? placeholder : ""}
                className="outline-none flex-1 min-w-20 text-sm py-0.5"
            />
        </div>
    );
}

TagInput.propTypes = {
    value:       PropTypes.arrayOf(PropTypes.string).isRequired,
    onChange:    PropTypes.func.isRequired,
    placeholder: PropTypes.string,
};

// Usage:
// <FormField label="Tags" htmlFor="tags">
//   <TagInput
//     value={form.tags}
//     onChange={(tags) => setForm(prev => ({ ...prev, tags }))}
//     placeholder="Add tags (press Enter)"
//   />
// </FormField>

Common Mistakes

Mistake 1 — Forgetting type=”button” on internal buttons

❌ Wrong — clicking the tag remove button submits the form:

<button onClick={() => removeTag(tag)}>×</button>
// Default type is "submit" — triggers form submission!

✅ Correct — all non-submit buttons need type=”button”:

<button type="button" onClick={() => removeTag(tag)}>×</button>   // ✓

Mistake 2 — Not passing unknown props to the DOM element (prop pollution)

❌ Wrong — isInvalid is passed to DOM <input>:

function Input({ isInvalid, ...rest }) {
    return <input isInvalid={isInvalid} {...rest} />;   // Warning: unknown DOM prop!
}

✅ Correct — use isInvalid for styling, spread only standard props:

function Input({ isInvalid, ...rest }) {
    return <input className={isInvalid ? "border-red-500" : ""} {...rest} />;   // ✓
}

Quick Reference

Component Props Purpose
FormField label, htmlFor, error, required, children Label + input + error wrapper
Input isInvalid, …rest (all input attrs) Styled input with error state
TagInput value: string[], onChange, placeholder Multi-value tag selector

🧠 Test Yourself

Inside a form, a TagInput component has a remove button for each tag. Without type="button" on the remove button, what happens when the user clicks it?