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}
/>
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.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.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 |