Skip to main content
paulund

5 min read

#react#forms#refs

Controlled vs Uncontrolled Forms in React

Every React form tutorial starts with controlled inputs. useState, onChange, sync the value on every keystroke. It works, and for a long time it was the only way most people handled forms. Then refs became easier to use, and suddenly the debate started: controlled or uncontrolled?

The honest answer is that the choice is usually obvious once you know what each pattern is actually good at.

What is useState

useState is a React hook that lets a component hold a piece of data and re-render when that data changes. You call it with an initial value and it returns the current value plus a setter function.

const [count, setCount] = useState(0)

count holds the current value. setCount updates it. Every time you call setCount, React re-renders the component with the new value.

For forms, useState is how you keep track of what the user has typed. Each input field gets its own state variable, updated on every keystroke via onChange. React then controls what the input displays by passing that state back as the value prop.

Controlled: React Owns the Value

A controlled input means React is the single source of truth for the field's value. Every keystroke updates state, and the input displays whatever state holds.

import { useState } from 'react'

export function SearchInput() {
    const [query, setQuery] = useState('')

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

Because query is always in sync, you can act on it immediately. Filter a list, validate in real time, enable or disable a submit button, show a character count. All of that is trivial when the value lives in state.

A login form is a good example of where controlled inputs earn their keep:

import { useState } from 'react'

export function LoginForm() {
    const [email, setEmail] = useState('')
    const [password, setPassword] = useState('')
    const [error, setError] = useState<string | null>(null)

    const isValid = email.includes('@') && password.length >= 8

    async function handleSubmit(e: React.FormEvent) {
        e.preventDefault()
        const result = await login(email, password)
        if (!result.ok) setError(result.message)
    }

    return (
        <form onSubmit={handleSubmit}>
            <input
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
            />
            <input
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
            />
            {error && <p>{error}</p>}
            <button type="submit" disabled={!isValid}>
                Log in
            </button>
        </form>
    )
}

The submit button is disabled until both fields pass validation. The error clears as the user types. None of this is possible without the values being in state.

The cost is re-renders. Every keystroke triggers a state update and a re-render of the component. For most forms this is not a problem. For very large forms or forms embedded in complex trees, it can become one.

What is useRef

useRef is a React hook that gives you a mutable object whose .current property persists across re-renders. Unlike useState, updating a ref does not trigger a re-render.

const inputRef = useRef<HTMLInputElement>(null)

The main use case in forms is attaching a ref to a DOM element. React sets .current to that element once it mounts, and you can read from it whenever you need to.

const value = inputRef.current?.value

Because nothing re-renders when the DOM changes, refs are a way to step outside React's rendering cycle and talk to the DOM directly. That's useful for uncontrolled inputs, but also for things like focusing an element or measuring its size.

Uncontrolled: the DOM Owns the Value

An uncontrolled input lets the browser manage the field. You read the value when you need it, usually on submit, via a ref.

import { useRef } from 'react'

export function ContactForm() {
    const nameRef = useRef<HTMLInputElement>(null)
    const messageRef = useRef<HTMLTextAreaElement>(null)

    function handleSubmit(e: React.FormEvent) {
        e.preventDefault()
        const name = nameRef.current?.value ?? ''
        const message = messageRef.current?.value ?? ''
        sendMessage({ name, message })
    }

    return (
        <form onSubmit={handleSubmit}>
            <input type="text" ref={nameRef} defaultValue="" />
            <textarea ref={messageRef} defaultValue="" />
            <button type="submit">Send</button>
        </form>
    )
}

No re-renders on every keystroke. No state to initialise. If you only care about the values at submission time and you don't need to validate or react to the input as it changes, this is simpler code.

The defaultValue prop sets the initial value without React trying to control the field. Leave it off and the browser starts blank, which is usually what you want.

When Each One Makes Sense

Controlled inputs are the right default when:

  • You need real-time validation or derived state from the field value
  • You want to enable or disable other UI based on what's typed
  • You need to clear or reset a field programmatically
  • The form is tied to a shared state store like Zustand or Jotai

Uncontrolled inputs work better when:

  • You only need the value at submit time and have no validation on the way
  • You're integrating with a non-React library that manages the DOM directly
  • You're building a file upload input (the file input is always uncontrolled in practice)
  • Re-render performance is measurably causing problems

In practice, most application forms are controlled. The re-render overhead is small, the code is more predictable, and the validation story is much cleaner.

React Hook Form Is Worth Mentioning

If your form has many fields, nested validation, or async checks, React Hook Form is worth considering. Internally it uses uncontrolled inputs with refs and only triggers re-renders when validation state changes. You get the performance of uncontrolled with a developer experience close to controlled.

That said, reach for it when a form is genuinely complex. A three-field contact form does not need a form library.

The Trade-offs Are Smaller Than the Debate Suggests

Most online discussions make this sound like a major architectural choice. It rarely is. Pick controlled inputs by default. Switch to uncontrolled only if you have a concrete reason: a specific performance problem, a DOM integration requirement, or a submit-only form where syncing state on every keystroke adds nothing.

The worst outcome is mixing both patterns without a reason, ending up with some fields in state and some in refs and no clear rule for which is which. Pick one per form and stay consistent.

Related notes


Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.