React Form Debug and Code Review: 6 Common State and Validation Bugs and How to Fix Them
In front-end technical screenings and pair-programming interviews, code-review and debugging rounds are common formats for evaluating candidates. Rather than asking candidates to build components from scratch, interviewers present short, realistic code snippets containing subtle bugs and ask candidates to:
- Identify the symptom observed by users or reported in automated tests.
- Isolate the exact root cause in the state lifecycle, event handling, or DOM synchronization.
- Provide a clean, robust, production-grade fix.
- Articulate the underlying architectural principles in spoken technical communication.
Forms and validation are high-frequency targets for these rounds. In real-world applications, forms combine asynchronous operations, controlled vs uncontrolled state transitions, race conditions, debounced validation, and accessible error reporting. When implemented poorly, forms suffer from cursor jumping, lost keystrokes, out-of-order validation responses, and broken keyboard navigation.
This guide covers 6 realistic React form debugging interview problems for developers with 0–5 years of experience.
Question Index
| # | Bug Focus | Core Concepts | Difficulty |
|---|---|---|---|
| 1 | Uncontrolled to Controlled Input Warning | Initial state, undefined vs empty string, DOM sync | Junior |
| 2 | Async Validation Race Condition | Race conditions, cancellation, stale responses | Mid |
| 3 | Form Submissions with Stale Closure State | Stale closures, functional state, native FormData | Mid |
| 4 | Debounced Input Lag and Cursor Jumping | Controlled input sync, re-render lag, cursor position | Mid |
| 5 | Custom Checkbox Group Array State Mutation | State immutability, reference equality, re-rendering | Junior-Mid |
| 6 | Inaccessible Form Validation & Missing ARIA Binding | Screen readers, aria-invalid, aria-describedby | Mid-Senior |
Q1. The "Uncontrolled to Controlled Input" Warning and Lost Default Values
Level: Junior
Focus Area: State initialization, undefined vs empty string, and DOM attribute binding.
Buggy Code Snippet
import { useState, useEffect } from 'react';
interface UserProfile {
name?: string;
email?: string;
}
export function EditProfileForm({ initialUser }: { initialUser?: UserProfile }) {
// Buggy initialization: initialUser may be undefined on first render
const [formData, setFormData] = useState({
name: initialUser?.name,
email: initialUser?.email,
});
// Sync state when initialUser loads asynchronously
useEffect(() => {
if (initialUser) {
setFormData({
name: initialUser.name,
email: initialUser.email,
});
}
}, [initialUser]);
return (
<form>
<input
type="text"
name="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<input
type="email"
name="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</form>
);
}
Symptom
When the component mounts, the browser console logs a warning: Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Furthermore, if initialUser loads asynchronously, the inputs remain uncontrolled initially, causing flickering or dropped focus.
Root Cause
In React, passing value={undefined} (or omitting value) marks an input element as uncontrolled, delegating value tracking to the browser DOM. When initialUser is undefined on mount, initialUser?.name evaluates to undefined. Later, when initialUser resolves and setFormData runs, value transitions from undefined to a string (e.g. 'Alice'), switching the input from uncontrolled to controlled. React forbids this transition because it causes unpredictable input state synchronization.
The Fix
Always initialize controlled inputs with defined fallback values (an empty string '' rather than undefined). Additionally, avoid unnecessary useEffect state syncing when initial props change; use controlled props or key-based re-mounting.
import { useState } from 'react';
interface UserProfile {
name: string;
email: string;
}
export function EditProfileForm({ initialUser }: { initialUser?: Partial<UserProfile> }) {
// Always initialize controlled input values with empty strings, never undefined
const [formData, setFormData] = useState({
name: initialUser?.name ?? '',
email: initialUser?.email ?? '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
return (
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="name-input">Name</label>
<input
id="name-input"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
/>
<label htmlFor="email-input">Email</label>
<input
id="email-input"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
</form>
);
}
Spoken Explanation to the Interviewer
"The issue occurs because the initial state values were evaluated to undefined. Passing undefined to an input's value prop tells React the input is uncontrolled. Once the data loaded and updated the state to a string, React logged the uncontrolled-to-controlled warning. The fix is ensuring controlled inputs always default to an empty string with nullish coalescing (?? '')."
Q2. Async Username Validation Race Condition
Level: Mid
Focus Area: Asynchronous validation, network race conditions, and AbortController.
Buggy Code Snippet
import { useState } from 'react';
export function UsernameInput() {
const [username, setUsername] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [availability, setAvailability] = useState<string | null>(null);
const checkAvailability = async (name: string) => {
if (!name) return;
setIsValidating(true);
// Simulated network API call
const res = await fetch(`/api/check-username?q=${encodeURIComponent(name)}`);
const data = await res.json();
// Bug: Stale response can overwrite a newer query
setAvailability(data.available ? 'Username available' : 'Username taken');
setIsValidating(false);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setUsername(next);
checkAvailability(next);
};
return (
<div>
<input value={username} onChange={handleChange} placeholder="Choose username" />
{isValidating && <span>Checking...</span>}
{availability && <span>{availability}</span>}
</div>
);
}
Symptom
When a user types quickly (e.g. typing "admin" and then backspacing to "ad"), the UI displays validation results for "admin" instead of "ad", or displays "Username taken" for an input that is actually available.
Root Cause
Network requests are asynchronous and non-deterministic. A fast response for "admin" (e.g., cached or small payload) might arrive after a slower response for "ad", overwriting the newer state with obsolete data. The code lacks request cancellation and response sequencing.
The Fix
Use an AbortController or a local active flag to cancel or ignore in-flight requests whenever the input changes, paired with debouncing.
import { useState, useEffect } from 'react';
export function UsernameInput() {
const [username, setUsername] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [availability, setAvailability] = useState<string | null>(null);
useEffect(() => {
const trimmed = username.trim();
if (!trimmed) {
setAvailability(null);
setIsValidating(false);
return;
}
const controller = new AbortController();
setIsValidating(true);
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/check-username?q=${encodeURIComponent(trimmed)}`, {
signal: controller.signal,
});
const data = await res.json();
setAvailability(data.available ? 'Username available' : 'Username taken');
} catch (err: unknown) {
if ((err as Error).name !== 'AbortError') {
setAvailability('Error checking username');
}
} finally {
if (!controller.signal.aborted) {
setIsValidating(false);
}
}
}, 300); // Debounce to avoid flooding the API
return () => {
clearTimeout(timer);
controller.abort(); // Cancel pending network request
};
}, [username]);
return (
<div>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose username"
/>
{isValidating && <span>Checking...</span>}
{availability && <span>{availability}</span>}
</div>
);
}
Spoken Explanation to the Interviewer
"Rapid keystrokes dispatch multiple asynchronous network requests. Because network latency varies, an earlier, slower request can resolve after a newer one, causing a classic async race condition. We resolve this by debouncing validation and leveraging AbortController in a useEffect cleanup function to abort stale in-flight requests."
Q3. Stale State on Multi-Step Form Submission
Level: Mid
Focus Area: Asynchronous submission, closures over stale state, and leveraging native FormData.
Buggy Code Snippet
import { useState } from 'react';
export function QuickFeedbackForm() {
const [rating, setRating] = useState(5);
const [comment, setComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
// Asynchronous tracking call
await fetch('/api/analytics/click', { method: 'POST' });
// Bug: rating and comment can be stale if updated during the async wait
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating, comment }),
});
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<select value={rating} onChange={(e) => setRating(Number(e.target.value))}>
<option value={1}>1 - Poor</option>
<option value={5}>5 - Excellent</option>
</select>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} />
<button type="submit" disabled={isSubmitting}>Submit</button>
</form>
);
}
Symptom
If the user adjusts their rating or changes their comment while the preliminary tracking call is running, the feedback submitted to /api/feedback contains the old rating rather than the user's latest input.
Root Cause
handleSubmit closes over the values of rating and comment at the moment the submit event was triggered. During the await fetch('/api/analytics/click') operation, any state updates produce new values for subsequent renders, but the executing async function continues to reference the captured closure variables.
The Fix
Extract values snapshot-style upfront synchronously at the start of submission, or read directly from the form event using the native FormData API.
import { useState } from 'react';
export function QuickFeedbackForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isSubmitting) return;
// Snapshot form values synchronously from native FormData
const formData = new FormData(e.currentTarget);
const submissionData = {
rating: Number(formData.get('rating')),
comment: String(formData.get('comment') ?? ''),
};
setIsSubmitting(true);
try {
await fetch('/api/analytics/click', { method: 'POST' });
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submissionData),
});
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<select name="rating" defaultValue={5}>
<option value={1}>1 - Poor</option>
<option value={5}>5 - Excellent</option>
</select>
<textarea name="comment" defaultValue="" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Spoken Explanation to the Interviewer
"Because asynchronous functions capture state variables in their closure at execution start, awaiting an asynchronous task introduces a window where state can diverge. Reading form data synchronously upfront using new FormData(e.currentTarget) captures the exact user values at submission time, decoupling the submission from asynchronous closure staleness."
Q4. Controlled Input Lag and Cursor Jumping on Format Masks
Level: Mid
Focus Area: Controlled input synchronization, selection range preservation, and debounce lag.
Buggy Code Snippet
import { useState } from 'react';
export function PhoneNumberInput() {
const [phone, setPhone] = useState('');
// Formatter function: formats digits as (XXX) XXX-XXXX
const formatPhone = (val: string) => {
const digits = val.replace(/\D/g, '').slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Bug: Asynchronously updating formatted value or debouncing controlled state
const formatted = formatPhone(e.target.value);
setPhone(formatted);
};
return (
<input
type="tel"
value={phone}
onChange={handleChange}
placeholder="(555) 000-0000"
/>
);
}
Symptom
When a user edits an existing phone number by placing the cursor in the middle of the string and typing or deleting a digit, the text cursor jumps to the end of the input field.
Root Cause
When a controlled input's value is modified by JavaScript formatting during onChange, React updates the underlying DOM input value. Browsers automatically reset the cursor (selectionStart and selectionEnd) to the end of the input whenever .value is reassigned programmatically unless the previous cursor position is calculated and restored.
The Fix
Track and preserve the cursor position across re-renders using a ref and setSelectionRange, or use an uncontrolled input pattern with an input mask listener.
import { useState, useRef, useLayoutEffect } from 'react';
export function PhoneNumberInput() {
const [phone, setPhone] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const cursorRef = useRef<number | null>(null);
const formatPhone = (val: string) => {
const digits = val.replace(/\D/g, '').slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target;
cursorRef.current = input.selectionStart;
setPhone(formatPhone(input.value));
};
useLayoutEffect(() => {
if (inputRef.current && cursorRef.current !== null) {
inputRef.current.setSelectionRange(cursorRef.current, cursorRef.current);
}
}, [phone]);
return (
<input
ref={inputRef}
type="tel"
value={phone}
onChange={handleChange}
placeholder="(555) 000-0000"
/>
);
}
Spoken Explanation to the Interviewer
"When a controlled component formats user input, programmatically rewriting the input's value resets the cursor position to the end of the string. To prevent cursor jumping, we record selectionStart before the state update and restore it synchronously via useLayoutEffect with setSelectionRange."
Q5. Direct Array Mutation in Dynamic Checkbox Groups
Level: Junior-Mid
Focus Area: React state immutability, reference equality, and re-rendering bailouts.
Buggy Code Snippet
import { useState } from 'react';
const HOBBIES = ['Reading', 'Gaming', 'Cooking', 'Hiking'];
export function HobbyPicker() {
const [selected, setSelected] = useState<string[]>([]);
const handleToggle = (hobby: string) => {
const list = selected;
const index = list.indexOf(hobby);
if (index > -1) {
// Bug: In-place mutation of state array
list.splice(index, 1);
} else {
// Bug: In-place mutation of state array
list.push(hobby);
}
// React skips re-rendering because list === selected
setSelected(list);
};
return (
<div>
{HOBBIES.map((hobby) => (
<label key={hobby}>
<input
type="checkbox"
checked={selected.includes(hobby)}
onChange={() => handleToggle(hobby)}
/>
{hobby}
</label>
))}
</div>
);
}
Symptom
Clicking checkboxes does not update the visual checkmark or check state, even though setSelected is called. The component fails to re-render.
Root Cause
const list = selected; copies the array reference, not the array data. Calling list.splice() or list.push() mutates the existing state array in place. When setSelected(list) is called, React performs an Object.is(prev, next) comparison. Because both reference the same array in memory, React bails out of re-rendering.
The Fix
Always return a new array reference when modifying array state, using .filter() to remove items and the spread operator [...prev, item] to append items.
import { useState } from 'react';
const HOBBIES = ['Reading', 'Gaming', 'Cooking', 'Hiking'];
export function HobbyPicker() {
const [selected, setSelected] = useState<string[]>([]);
const handleToggle = (hobby: string) => {
setSelected((prev) =>
prev.includes(hobby)
? prev.filter((item) => item !== hobby) // New array reference
: [...prev, hobby] // New array reference
);
};
return (
<fieldset>
<legend>Select your hobbies</legend>
{HOBBIES.map((hobby) => (
<label key={hobby}>
<input
type="checkbox"
checked={selected.includes(hobby)}
onChange={() => handleToggle(hobby)}
/>
{hobby}
</label>
))}
</fieldset>
);
}
Spoken Explanation to the Interviewer
"React relies on reference equality (Object.is) to detect state changes. Mutating selected via push or splice modifies the array in place, so React sees identical object references and cancels re-rendering. We fix this by returning a new array using functional state updates with filter and array spreading."
Q6. Inaccessible Custom Validation and Broken Screen Reader Announcements
Level: Mid-Senior
Focus Area: Form accessibility, WAI-ARIA, aria-invalid, aria-describedby, and focus management.
Buggy Code Snippet
import { useState } from 'react';
export function SignupForm() {
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.includes('@')) {
// Inaccessible error: rendered visually as plain text, no ARIA bindings
setError('Please enter a valid email address.');
return;
}
setError(null);
};
return (
<form onSubmit={handleSubmit}>
{/* Bug: input has no label association, no aria-invalid, no aria-describedby */}
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter email"
/>
{error && <span style={{ color: 'red' }}>{error}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
Symptom
Screen reader users receive no notification that an error occurred upon form submission. When navigating back to the input, screen readers do not announce that the field is invalid, nor do they read the error message.
Root Cause
- The input lacks an accessible name because it has no associated
<label>(relying solely onplaceholder). - The input does not communicate error state via
aria-invalid="true". - The error message element is not linked to the input via
aria-describedby="error-id". - Focus is not moved to the invalid input upon submission failure, forcing keyboard users to manually tab around to locate errors.
The Fix
Associate inputs with explicit <label> tags, link error messages with aria-describedby, set aria-invalid, use an accessible role="alert" for dynamic announcements, and move focus to the first invalid field.
import { useState, useRef } from 'react';
export function SignupForm() {
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const emailInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.includes('@')) {
setError('Please enter a valid email address.');
// Move focus to first invalid control
emailInputRef.current?.focus();
return;
}
setError(null);
};
const hasError = Boolean(error);
const errorId = 'email-error-message';
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email-field">Email address</label>
<input
id="email-field"
ref={emailInputRef}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
/>
{error && (
<span id={errorId} role="alert" className="error-message">
{error}
</span>
)}
<button type="submit">Sign Up</button>
</form>
);
}
Spoken Explanation to the Interviewer
"Visual error text alone is inaccessible to assistive technologies. An accessible form requires four key bindings: a proper <label> with htmlFor, aria-invalid={true} on error, aria-describedby referencing the error container's ID, and role='alert' to announce the validation failure immediately. When validation fails, focus should automatically shift to the first invalid input."
Preparation Checklist
To master React form debugging and code-review rounds before your technical interview, review this actionable checklist:
- Avoid uncontrolled-to-controlled transitions: Always initialize controlled inputs with
''rather thanundefined. - Cancel async validation requests: Use
AbortControllerinuseEffectcleanup to cancel in-flight requests and prevent race conditions. - Avoid stale closure state in async submits: Read form data synchronously with
new FormData(e.currentTarget)upfront. - Preserve selection range on masked inputs: Record
selectionStartand restore it synchronously viauseLayoutEffectto avoid cursor jumping. - Maintain state immutability: Always create new array/object references when updating state to prevent
Object.isbailout bugs. - Wire up form accessibility: Always provide
<label htmlFor="...">,aria-invalid,aria-describedby, androle="alert"for error messages. - Move focus on submission failure: Programmatically focus the first invalid input using a ref to support keyboard-only users.
Conclusion
Form validation and state synchronization are standard litmus tests in front-end interviews because they expose whether a candidate understands the browser DOM and React reconciliation beneath surface-level syntax. By systematically diagnosing root causes—from reference equality to async race conditions—you can explain clean fixes with senior-level precision.