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 |