Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Forms & Controlled Components

Forms are fundamental to web applications. React provides two approaches: Controlled Components (React manages state) and Uncontrolled Components (DOM manages state). Real-world analogy: A controlled component is like a puppet on strings — React pulls every string. The input displays exactly what React state says, and every keystroke feeds back through onChange to update that state. An uncontrolled component is like a self-service kiosk — the DOM handles the value internally, and you only read it when you need it (on submit). Most of the time, you want the puppet approach because it gives you full control over validation, formatting, and conditional logic on every keystroke.

Controlled vs Uncontrolled

AspectControlledUncontrolled
State locationReact stateDOM
Access to valueuseState variableuseRef
ValidationOn every keystrokeOn submit
Use caseMost formsFile inputs, quick prototypes
Controlled Component:
┌─────────────────────────────────────────────────────────┐
│  User types →  onChange  →  setState  →  re-render     │
│                    ↓                                    │
│              value={state}                              │
│                    ↓                                    │
│              Input displays state value                 │
└─────────────────────────────────────────────────────────┘

Uncontrolled Component:
┌─────────────────────────────────────────────────────────┐
│  User types →  DOM handles value                        │
│                    ↓                                    │
│  On submit  →  ref.current.value                       │
└─────────────────────────────────────────────────────────┘

Controlled Components

With controlled components, React state is the “single source of truth” for input values.

Basic Text Input

import { useState } from 'react';

function TextInput() {
  const [value, setValue] = useState('');

  // Every keystroke triggers this cycle:
  // 1. User types a character
  // 2. onChange fires with the new input value
  // 3. setValue updates React state
  // 4. Component re-renders with the new value
  // 5. The input displays the value from state
  // This "controlled loop" means React is always the source of truth.
  const handleChange = (event) => {
    setValue(event.target.value);
  };

  return (
    <div>
      <input
        type="text"
        value={value}           // Controlled: display what state says
        onChange={handleChange}  // Update state on every keystroke
        placeholder="Type something..."
      />
      <p>You typed: {value}</p>
    </div>
  );
}
Common pitfall — missing onChange on a controlled input: If you set value on an input but forget onChange, the input becomes read-only. React will not let the user type because the input always resets to the state value, and without onChange, the state never updates. You will see a React warning: “You provided a value prop to a form field without an onChange handler.”

Complete Form Example

function RegistrationForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    agreeToTerms: false,
    role: 'user',
    interests: []
  });

  const handleChange = (event) => {
    const { name, value, type, checked } = event.target;
    
    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value
    }));
  };

  const handleInterestChange = (event) => {
    const { value, checked } = event.target;
    
    setFormData(prev => ({
      ...prev,
      interests: checked
        ? [...prev.interests, value]
        : prev.interests.filter(i => i !== value)
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('Form submitted:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Text Input */}
      <div>
        <label htmlFor="username">Username:</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          required
        />
      </div>

      {/* Email Input */}
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          required
        />
      </div>

      {/* Password Inputs */}
      <div>
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          minLength={8}
          required
        />
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password:</label>
        <input
          type="password"
          id="confirmPassword"
          name="confirmPassword"
          value={formData.confirmPassword}
          onChange={handleChange}
          required
        />
      </div>

      {/* Select Dropdown */}
      <div>
        <label htmlFor="role">Role:</label>
        <select
          id="role"
          name="role"
          value={formData.role}
          onChange={handleChange}
        >
          <option value="user">User</option>
          <option value="admin">Admin</option>
          <option value="moderator">Moderator</option>
        </select>
      </div>

      {/* Checkbox Group */}
      <fieldset>
        <legend>Interests:</legend>
        {['Technology', 'Sports', 'Music', 'Art'].map(interest => (
          <label key={interest}>
            <input
              type="checkbox"
              value={interest}
              checked={formData.interests.includes(interest)}
              onChange={handleInterestChange}
            />
            {interest}
          </label>
        ))}
      </fieldset>

      {/* Single Checkbox */}
      <div>
        <label>
          <input
            type="checkbox"
            name="agreeToTerms"
            checked={formData.agreeToTerms}
            onChange={handleChange}
            required
          />
          I agree to the terms and conditions
        </label>
      </div>

      <button type="submit">Register</button>
    </form>
  );
}

Form Elements Reference

Textarea

In React, <textarea> uses a value prop instead of children:
// ❌ HTML way
<textarea>Initial text</textarea>

// ✅ React way
<textarea 
  value={text} 
  onChange={(e) => setText(e.target.value)} 
/>

Select

Use value on the <select> element, not selected on options:
// ❌ HTML way
<select>
  <option selected>Apple</option>
  <option>Banana</option>
</select>

// ✅ React way
<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
  <option value="apple">Apple</option>
  <option value="banana">Banana</option>
</select>

Multiple Select

function MultiSelect() {
  const [selected, setSelected] = useState([]);

  const handleChange = (e) => {
    const options = Array.from(e.target.selectedOptions);
    setSelected(options.map(option => option.value));
  };

  return (
    <select multiple value={selected} onChange={handleChange}>
      <option value="react">React</option>
      <option value="vue">Vue</option>
      <option value="angular">Angular</option>
    </select>
  );
}

Radio Buttons

function RadioGroup() {
  const [selected, setSelected] = useState('medium');

  return (
    <fieldset>
      <legend>Size:</legend>
      {['small', 'medium', 'large'].map(size => (
        <label key={size}>
          <input
            type="radio"
            name="size"
            value={size}
            checked={selected === size}
            onChange={(e) => setSelected(e.target.value)}
          />
          {size.charAt(0).toUpperCase() + size.slice(1)}
        </label>
      ))}
    </fieldset>
  );
}

Form Validation

Real-Time Validation

function ValidatedForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validate = (name, value) => {
    switch (name) {
      case 'email':
        if (!value) return 'Email is required';
        if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format';
        return '';
      case 'password':
        if (!value) return 'Password is required';
        if (value.length < 8) return 'Password must be at least 8 characters';
        if (!/[A-Z]/.test(value)) return 'Password must contain uppercase letter';
        if (!/[0-9]/.test(value)) return 'Password must contain a number';
        return '';
      default:
        return '';
    }
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    
    // Validate on change if field has been touched
    if (touched[name]) {
      setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
    }
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    
    // Validate all fields
    const newErrors = {};
    Object.keys(formData).forEach(key => {
      const error = validate(key, formData[key]);
      if (error) newErrors[key] = error;
    });
    
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      setTouched({ email: true, password: true });
      return;
    }
    
    console.log('Form is valid:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={errors.email && touched.email ? 'error' : ''}
        />
        {errors.email && touched.email && (
          <span className="error-message">{errors.email}</span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={errors.password && touched.password ? 'error' : ''}
        />
        {errors.password && touched.password && (
          <span className="error-message">{errors.password}</span>
        )}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

Password Strength Indicator

function PasswordStrength({ password }) {
  const getStrength = (pwd) => {
    let strength = 0;
    if (pwd.length >= 8) strength++;
    if (pwd.length >= 12) strength++;
    if (/[A-Z]/.test(pwd)) strength++;
    if (/[a-z]/.test(pwd)) strength++;
    if (/[0-9]/.test(pwd)) strength++;
    if (/[^A-Za-z0-9]/.test(pwd)) strength++;
    return strength;
  };

  const strength = getStrength(password);
  const labels = ['', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong', 'Excellent'];
  const colors = ['', '#ef4444', '#f97316', '#eab308', '#22c55e', '#16a34a', '#15803d'];

  return (
    <div className="password-strength">
      <div className="strength-bars">
        {[1, 2, 3, 4, 5, 6].map(level => (
          <div
            key={level}
            className="strength-bar"
            style={{
              backgroundColor: strength >= level ? colors[strength] : '#e5e7eb',
              height: '4px',
              flex: 1,
              marginRight: '2px',
              borderRadius: '2px'
            }}
          />
        ))}
      </div>
      <span style={{ color: colors[strength] }}>{labels[strength]}</span>
    </div>
  );
}

Uncontrolled Components with useRef

For simple forms or when you need direct DOM access:
import { useRef } from 'react';

function UncontrolledForm() {
  const nameRef = useRef();
  const emailRef = useRef();
  const fileRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({
      name: nameRef.current.value,
      email: emailRef.current.value,
      file: fileRef.current.files[0]
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="John" />
      <input ref={emailRef} type="email" />
      <input ref={fileRef} type="file" />
      <button type="submit">Submit</button>
    </form>
  );
}
File inputs are always uncontrolled in React because their value can only be set by the user, not programmatically.
// File input example
function FileUpload() {
  const [file, setFile] = useState(null);

  const handleChange = (e) => {
    const selectedFile = e.target.files[0];
    setFile(selectedFile);
  };

  return (
    <div>
      <input type="file" onChange={handleChange} accept="image/*" />
      {file && <p>Selected: {file.name}</p>}
    </div>
  );
}

Form Libraries

For complex forms, consider using a form library:

React Hook Form

npm install react-hook-form
import { useForm } from 'react-hook-form';

function HookFormExample() {
  const { 
    register, 
    handleSubmit, 
    formState: { errors, isSubmitting } 
  } = useForm();

  const onSubmit = async (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^\S+@\S+$/i,
              message: 'Invalid email format'
            }
          })}
          placeholder="Email"
        />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <input
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters'
            }
          })}
          placeholder="Password"
        />
        {errors.password && <span>{errors.password.message}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}
React Hook Form advantages:
  • Less re-renders (uses refs under the hood)
  • Built-in validation
  • Easy integration with UI libraries
  • Small bundle size

Form Pitfalls

Pitfall 1 — Controlled input without onChange: If you set value={something} on an input but forget to provide an onChange handler, the input becomes read-only. React enforces the value from state on every render, so the user types but nothing appears. The fix: always pair value with onChange, or use defaultValue for uncontrolled inputs.Pitfall 2 — Losing focus on every keystroke: If your input component is defined inside another component’s render, React creates a new component type on every render, unmounts the old input, and mounts a new one — losing focus. Always define input components outside the parent component or use useMemo.
// BAD: InputField is redefined every render, causing unmount/remount
function Form() {
  const InputField = () => <input />;
  return <InputField />;
}

// GOOD: defined outside the component
const InputField = () => <input />;
function Form() {
  return <InputField />;
}
Pitfall 3 — Using index as key for dynamic form fields: When you add/remove form fields dynamically and use array index as the key, React reuses the wrong input DOM elements. The user deletes field 2 of 3, but the values from field 3 appear in field 2’s position because the input element was reused. Always use a stable unique ID.

Advanced Patterns

Debounced Input

import { useState, useEffect, useCallback } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      // Make API call
      console.log('Searching for:', debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input
      type="text"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

Form with Dynamic Fields

function DynamicForm() {
  const [fields, setFields] = useState([{ id: 1, value: '' }]);

  const addField = () => {
    setFields([...fields, { id: Date.now(), value: '' }]);
  };

  const removeField = (id) => {
    if (fields.length > 1) {
      setFields(fields.filter(f => f.id !== id));
    }
  };

  const updateField = (id, value) => {
    setFields(fields.map(f => f.id === id ? { ...f, value } : f));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Values:', fields.map(f => f.value));
  };

  return (
    <form onSubmit={handleSubmit}>
      {fields.map((field, index) => (
        <div key={field.id} className="field-row">
          <input
            value={field.value}
            onChange={(e) => updateField(field.id, e.target.value)}
            placeholder={`Field ${index + 1}`}
          />
          <button 
            type="button" 
            onClick={() => removeField(field.id)}
            disabled={fields.length === 1}
          >
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={addField}>Add Field</button>
      <button type="submit">Submit</button>
    </form>
  );
}

🎯 Practice Exercises

function CreditCardForm() {
  const [card, setCard] = useState({
    number: '',
    name: '',
    expiry: '',
    cvv: ''
  });

  const formatCardNumber = (value) => {
    return value
      .replace(/\D/g, '')
      .slice(0, 16)
      .replace(/(.{4})/g, '$1 ')
      .trim();
  };

  const formatExpiry = (value) => {
    return value
      .replace(/\D/g, '')
      .slice(0, 4)
      .replace(/^(.{2})/, '$1/');
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    
    let formatted = value;
    if (name === 'number') formatted = formatCardNumber(value);
    if (name === 'expiry') formatted = formatExpiry(value);
    if (name === 'cvv') formatted = value.replace(/\D/g, '').slice(0, 3);
    
    setCard(prev => ({ ...prev, [name]: formatted }));
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); console.log(card); }}>
      <input
        name="number"
        value={card.number}
        onChange={handleChange}
        placeholder="1234 5678 9012 3456"
        maxLength={19}
      />
      <input
        name="name"
        value={card.name}
        onChange={(e) => setCard(prev => ({ ...prev, name: e.target.value.toUpperCase() }))}
        placeholder="CARDHOLDER NAME"
      />
      <input
        name="expiry"
        value={card.expiry}
        onChange={handleChange}
        placeholder="MM/YY"
        maxLength={5}
      />
      <input
        name="cvv"
        value={card.cvv}
        onChange={handleChange}
        placeholder="CVV"
        maxLength={3}
        type="password"
      />
      <button type="submit">Pay</button>
    </form>
  );
}
function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [data, setData] = useState({
    // Step 1
    firstName: '',
    lastName: '',
    // Step 2
    email: '',
    phone: '',
    // Step 3
    plan: 'basic',
    addons: []
  });

  const updateData = (field, value) => {
    setData(prev => ({ ...prev, [field]: value }));
  };

  const next = () => setStep(s => Math.min(s + 1, 4));
  const prev = () => setStep(s => Math.max(s - 1, 1));

  return (
    <div className="multi-step">
      <div className="steps-indicator">
        {[1, 2, 3, 4].map(s => (
          <div 
            key={s} 
            className={`step ${step >= s ? 'active' : ''}`}
          >
            {s}
          </div>
        ))}
      </div>

      {step === 1 && (
        <div className="step-content">
          <h2>Personal Info</h2>
          <input
            value={data.firstName}
            onChange={(e) => updateData('firstName', e.target.value)}
            placeholder="First Name"
          />
          <input
            value={data.lastName}
            onChange={(e) => updateData('lastName', e.target.value)}
            placeholder="Last Name"
          />
        </div>
      )}

      {step === 2 && (
        <div className="step-content">
          <h2>Contact Info</h2>
          <input
            type="email"
            value={data.email}
            onChange={(e) => updateData('email', e.target.value)}
            placeholder="Email"
          />
          <input
            type="tel"
            value={data.phone}
            onChange={(e) => updateData('phone', e.target.value)}
            placeholder="Phone"
          />
        </div>
      )}

      {step === 3 && (
        <div className="step-content">
          <h2>Select Plan</h2>
          {['basic', 'pro', 'enterprise'].map(plan => (
            <label key={plan}>
              <input
                type="radio"
                name="plan"
                value={plan}
                checked={data.plan === plan}
                onChange={(e) => updateData('plan', e.target.value)}
              />
              {plan.charAt(0).toUpperCase() + plan.slice(1)}
            </label>
          ))}
        </div>
      )}

      {step === 4 && (
        <div className="step-content">
          <h2>Confirm</h2>
          <pre>{JSON.stringify(data, null, 2)}</pre>
        </div>
      )}

      <div className="buttons">
        {step > 1 && <button onClick={prev}>Back</button>}
        {step < 4 ? (
          <button onClick={next}>Next</button>
        ) : (
          <button onClick={() => alert('Submitted!')}>Submit</button>
        )}
      </div>
    </div>
  );
}

Summary

ConceptDescription
Controlled ComponentsReact state manages form input values
Uncontrolled ComponentsDOM manages values, accessed via refs
value + onChangeRequired pattern for controlled inputs
event.targetAccess input name, value, type, checked
Real-time ValidationValidate on change or blur
touched stateTrack if user has interacted with field
useRefAccess DOM elements directly
React Hook FormPopular library for complex forms
DebouncingDelay processing for search inputs

Next Steps

In the next chapter, you’ll learn about useEffect & Side Effects — fetching data, subscriptions, and more!

Interview Deep-Dive

Strong Answer: I default to controlled components for nearly all form inputs because React state is the single source of truth, enabling real-time validation, conditional formatting, dependent field logic, and programmatic value changes. But there are legitimate cases for uncontrolled.File inputs must be uncontrolled — you cannot programmatically set a file input’s value in the browser for security reasons. Rich text editors like Draft.js or Slate manage their own internal state and only expose values through refs or callbacks — wrapping them in controlled state would double the state management and cause input lag.Performance-wise, a controlled input triggers a React re-render on every keystroke. For a simple form, this is invisible. But I have seen it matter in two scenarios: first, a form inside a large component tree where a single keystroke re-renders hundreds of child components (the fix is memoization or splitting the form into its own component, not switching to uncontrolled). Second, real-time collaborative editing where latency matters — each keystroke round-trips through React state, which introduces a frame of delay. In that case, the editor manages its own DOM state and syncs with React asynchronously.React Hook Form is the hybrid approach: it uses uncontrolled inputs by default (registering refs) but provides a controlled API when needed. This gives you form validation without per-keystroke re-renders. On a form with 50 fields, the difference between 50 re-renders per keystroke (fully controlled) and 0 re-renders per keystroke (React Hook Form) is measurable.Follow-up: If you have a controlled input and the user types very fast, can React “drop” keystrokes? What causes this and how do you fix it?In theory, React’s synchronous state updates in event handlers should process every keystroke. But in practice, if the onChange handler triggers expensive work (like re-rendering a large component tree or running a complex validation), the browser may feel laggy and the user perceives dropped input.In React 18 with concurrent rendering, if you wrap the state update in startTransition, React can defer the re-render, but the input itself stays responsive because urgent updates (the actual input value) are not deferred. However, if you accidentally put the setValue call inside startTransition, the input value update is deferred and the input appears to “freeze.”The fix for expensive onChange handlers is to separate the input state from the derived computation: update the input value immediately (no transition), and debounce or transition the expensive computation. For example, setQuery(e.target.value) immediately, but startTransition(() => setFilteredResults(expensiveFilter(e.target.value))).
Strong Answer: There are three layers, and production forms usually need all three:Inline validation runs on every change or blur. It gives immediate feedback for simple rules — required fields, email format, password strength, min/max length. The implementation is straightforward: a validate function per field that returns an error string or empty. The tradeoff is that complex cross-field validations (password must differ from email, end date must be after start date) require access to the entire form state, which makes per-field validators awkward.Schema validation uses a declarative schema (Yup, Zod, Joi) to define all rules in one place. You validate the entire form object against the schema. This is cleaner for complex forms because cross-field rules are natural, the schema is reusable (server and client), and error messages are centralized. The tradeoff is that validating the entire schema on every keystroke can be wasteful — so libraries like React Hook Form validate only the changed field while still supporting full-schema validation on submit.Server-side validation is the only validation that matters for security. Client-side validation improves UX but can be bypassed. For uniqueness checks (username taken, email already registered), you must hit the server. The pattern: debounce the input, send an async validation request, and display the result. The UX challenge is showing a loading state during the async check and not blocking form submission while waiting.In practice, I use Zod for schema definition (because it gives you TypeScript types for free), React Hook Form for registration and change tracking, and server-side validation as the final gate. The schema is shared between frontend and backend via a shared package, ensuring rules stay in sync.Follow-up: How do you handle displaying server-side validation errors that the client did not catch? For example, “this email is already registered.”The form’s submit handler catches the server error and maps it to the correct field. With React Hook Form, you call setError('email', { type: 'server', message: 'This email is already registered' }). The error displays next to the email field just like a client-side validation error.The UX subtlety: the server error should clear when the user modifies the email field, not persist forever. React Hook Form handles this automatically because setError errors are cleared on the next valid change. For custom implementations, you clear the server error in the field’s onChange handler.For forms with multiple server errors (like a bulk import that returns errors per row), I return a structured error object from the API that maps field names to error messages, then loop through them and set each one. The important thing is that the error format is consistent between client validation and server validation so the UI rendering logic does not care about the error source.
Strong Answer: React renders the input with the value locked to whatever state holds. When the user types, the browser briefly shows the new character, then React re-renders and resets the input to the unchanged state value. The visual effect is that typing appears to do nothing — the input is “frozen.”React also logs a warning in development: “You provided a value prop to a form field without an onChange handler. This will render a read-only field.” This warning exists because it is one of the most common form bugs, especially for developers coming from vanilla HTML where inputs manage their own state.The reason this happens is the controlled component contract: by setting value, you tell React “I am the source of truth.” React enforces this by resetting the DOM input value to match the React state on every render. Without onChange to update state, the state never changes, so the input never changes.Two ways to fix it: add an onChange handler that calls setState, or switch to defaultValue if you want the DOM to manage the value (uncontrolled pattern). A readOnly prop suppresses the warning if the read-only behavior is intentional.The gotcha for teams: if someone adds value="" as a placeholder instead of placeholder="...", the input becomes a controlled empty input with no onChange — permanently blank. I have seen this shipped to production.Follow-up: You have a form where one field’s value depends on another (e.g., selecting a country populates a state/province dropdown). How do you handle the cascade without causing double renders?The key is computing the dependent value during render rather than in an effect. When the country changes, the provinces list is derived data: const provinces = countryToProvinces[selectedCountry]. This runs synchronously in the same render — no extra render cycle.For the selected province, you need to reset it when the country changes. The clean way is using a key prop on the province select: <ProvinceSelect key={selectedCountry} ... />. This remounts the component with fresh state. Alternatively, in the country onChange handler, set both values: setCountry(newCountry); setProvince('').Avoid using useEffect(() => { setProvince(''); }, [country]) because it causes a double render: first render with old province and new country, then effect fires and triggers second render with empty province. The user might briefly see an invalid combination. Setting both in the event handler keeps everything in one render cycle thanks to React’s batching.