Back to home

React Design Patterns: Controlled and Uncontrolled Components

Oct 18, 2026

Controlled and uncontrolled components describe where a component's state is managed. An uncontrolled component manages its state internally, so the parent normally receives the data only after a specific event. A controlled component receives its state and behavior through props, which allows the parent to decide what the component should display and how it should behave.

Both patterns are common in React. The difference becomes easier to understand when we build the same feature in both ways. In this article, we will compare controlled and uncontrolled forms, modals, and onboarding flows.

What Is an Uncontrolled Component?

An uncontrolled component manages its own internal state. The parent component does not have direct access to that state while the user is interacting with the component.

An uncontrolled form is a common example. The input values remain in the DOM, and the application reads them only when the user submits the form. The component can use refs to reach those DOM elements and retrieve their values.

The important point is that the parent cannot directly control the component's current values or behavior. It may receive the final data through a function such as onSubmit, but the component keeps control until that event happens.

What Is a Controlled Component?

A controlled component receives its state from outside through props. The parent stores the state and passes both the current value and the functions needed to change it.

Because the parent has access to the current state, it can change the component's behavior, add conditions, and provide a particular state during testing. This normally makes controlled components more reusable, flexible, and easier to test.

The basic difference looks like this:

  • An uncontrolled component owns and changes its internal state.
  • A controlled component receives its state and behavior from its parent.
  • Uncontrolled data is commonly read after an event such as form submission.
  • Controlled data is available while the user interacts with the component.

Creating an Uncontrolled Form

An uncontrolled form does not keep every input value in React state. Instead, it can attach a ref to each input and read the current values when the form is submitted.

import { createRef } from "react";

export const UncontrolledForm = () => {
  const nameInputRef = createRef();
  const ageInputRef = createRef();

  const submitHandler = (event) => {
    event.preventDefault();

    console.log(nameInputRef.current.value);
    console.log(ageInputRef.current.value);
  };

  return (
    <form onSubmit={submitHandler}>
      <input
        ref={nameInputRef}
        name="name"
        type="text"
        placeholder="Enter your name"
      />

      <input
        ref={ageInputRef}
        name="age"
        type="number"
        placeholder="Enter your age"
      />

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

nameInputRef and ageInputRef are attached to the two input elements. When the user submits the form, current.value gives us the current value of each input. event.preventDefault() prevents the browser from refreshing the page during submission.

The values are not tracked through React state as the user types. They are read indirectly from the DOM only when the submit event happens, which is why this form is uncontrolled.

Converting the Form into a Controlled Form

In a controlled form, React state stores the input values. Each input receives its current value from state and updates that state through onChange.

import { useEffect, useState } from "react";

export const ControlledForm = () => {
  const [name, setName] = useState("");
  const [age, setAge] = useState("");
  const [error, setError] = useState("");

  useEffect(() => {
    if (name.length < 1) {
      setError("Name cannot be empty");
    } else {
      setError("");
    }
  }, [name]);

  return (
    <form>
      <input
        name="name"
        type="text"
        placeholder="Enter your name"
        value={name}
        onChange={(event) => setName(event.target.value)}
      />

      <input
        name="age"
        type="number"
        placeholder="Enter your age"
        value={age}
        onChange={(event) => setAge(event.target.value)}
      />

      {error && <p>{error}</p>}
      <button>Submit</button>
    </form>
  );
};

The name and age states contain the current input values. When the user types, the input's change event updates the related state, and that state is passed back into the input through value.

This makes it easier to react to a value before the user submits the form. The effect listens for changes to name. If the value becomes empty, it adds an error message. When the user starts typing again, it clears that error.

This is the main flexibility of a controlled form. The application already knows the current data, so it can run validation while the user is entering it instead of waiting for submission.

Uncontrolled and Controlled Modals

The same idea applies to components other than forms. A modal is uncontrolled when it stores its visibility state inside itself.

import { useState } from "react";

export const UncontrolledModal = ({ children }) => {
  const [show, setShow] = useState(false);

  return (
    <>
      <button onClick={() => setShow(true)}>Show modal</button>

      {show && (
        <div>
          <button onClick={() => setShow(false)}>Hide modal</button>
          {children}
        </div>
      )}
    </>
  );
};

This modal can show and hide itself because it owns both show and setShow. That works until another component needs to control it. For example, a button in the parent cannot directly display the modal because the parent has no access to its internal state.

The modal becomes controlled when the visibility state moves to the parent. The modal only receives the current value and a function for closing itself.

export const ControlledModal = ({
  shouldDisplay,
  onClose,
  children,
}) => {
  if (!shouldDisplay) return null;

  return (
    <div>
      <button onClick={onClose}>Hide modal</button>
      {children}
    </div>
  );
};

The parent can now own the state and decide when the modal should appear:

import { useState } from "react";
import { ControlledModal } from "./ControlledModal";

export default function App() {
  const [shouldDisplay, setShouldDisplay] = useState(false);

  return (
    <>
      <button
        onClick={() => setShouldDisplay(!shouldDisplay)}
      >
        {shouldDisplay ? "Hide modal" : "Display modal"}
      </button>

      <ControlledModal
        shouldDisplay={shouldDisplay}
        onClose={() => setShouldDisplay(false)}
      >
        <h3>I am the body of the modal</h3>
      </ControlledModal>
    </>
  );
}

App controls the value of shouldDisplay. The outside button can toggle that value, and the modal can request to close by calling the function passed through onClose. The modal no longer decides its visibility internally.

Building an Uncontrolled Onboarding Flow

An onboarding flow contains several steps. Each step can collect some data and use a Next button to move the user forward. The flow finishes after the user completes the final step.

An uncontrolled flow keeps the collected data and current step index inside the flow component:

import React, { useState } from "react";

export const UncontrolledFlow = ({ children, onDone }) => {
  const [data, setData] = useState({});
  const [currentStepIndex, setCurrentStepIndex] = useState(0);

  const childrenArray = React.Children.toArray(children);
  const currentChild = childrenArray[currentStepIndex];

  const goNext = (dataFromStep) => {
    const nextStepIndex = currentStepIndex + 1;
    const newData = { ...data, ...dataFromStep };

    setData(newData);

    if (nextStepIndex < childrenArray.length) {
      setCurrentStepIndex(nextStepIndex);
    } else {
      onDone(newData);
    }
  };

  if (React.isValidElement(currentChild)) {
    return React.cloneElement(currentChild, { goNext });
  }

  return currentChild;
};

React.Children.toArray turns the children into an array, even when only one child is passed. The current step index selects the child that should be displayed. React.cloneElement gives that child a goNext prop.

Each step calls goNext with the data it has collected:

const StepOne = ({ goNext }) => (
  <>
    <h1>Enter your name</h1>
    <button onClick={() => goNext({ name: "Alex" })}>
      Next
    </button>
  </>
);

const StepTwo = ({ goNext }) => (
  <>
    <h1>Enter your age</h1>
    <button onClick={() => goNext({ age: 23 })}>
      Next
    </button>
  </>
);

const StepThree = ({ goNext }) => (
  <>
    <h1>Enter your country</h1>
    <button onClick={() => goNext({ country: "Mars" })}>
      Next
    </button>
  </>
);

The flow can then wrap all three steps:

<UncontrolledFlow
  onDone={(data) => {
    console.log(data);
    alert("You made it to the final step");
  }}
>
  <StepOne />
  <StepTwo />
  <StepThree />
</UncontrolledFlow>

Every time the user clicks Next, the flow combines the new step data with the existing object. If another child remains, it increases the index. Otherwise, it calls onDone with the completed data.

The flow works, but its parent has almost no control over the collected values. If the parent needs to include or remove a later step based on an earlier answer, that becomes difficult because the relevant state is hidden inside UncontrolledFlow.

Converting the Onboarding Flow into a Controlled Flow

The controlled version removes the data and index state from the flow. It receives the current index and the next-step function through props.

import React from "react";

export const ControlledFlow = ({
  children,
  currentIndex,
  onNext,
}) => {
  const childrenArray = React.Children.toArray(children);
  const currentChild = childrenArray[currentIndex];

  const goNext = (dataFromStep) => {
    onNext(dataFromStep);
  };

  if (React.isValidElement(currentChild)) {
    return React.cloneElement(currentChild, { goNext });
  }

  return currentChild;
};

This component only displays the child at currentIndex. When that child provides some data, the flow passes it to the parent's onNext function.

The parent is now responsible for storing the data and changing the step:

import { useState } from "react";
import { ControlledFlow } from "./ControlledFlow";

const GiftStep = ({ goNext }) => (
  <>
    <h1>Congratulations! You qualify for the gift.</h1>
    <button onClick={() => goNext({})}>Next</button>
  </>
);

export default function App() {
  const [data, setData] = useState({});
  const [currentStepIndex, setCurrentStepIndex] = useState(0);

  const goNext = (dataFromStep) => {
    setData({ ...data, ...dataFromStep });
    setCurrentStepIndex(currentStepIndex + 1);
  };

  return (
    <ControlledFlow
      currentIndex={currentStepIndex}
      onNext={goNext}
    >
      <StepOne />
      <StepTwo />
      {data.age >= 25 && <GiftStep />}
      <StepThree />
    </ControlledFlow>
  );
}

Because App owns the collected data, it can use that data while creating the list of steps. In this example, GiftStep appears only when the age collected by StepTwo is at least 25. If the age is below 25, the flow moves directly to the next available step.

This condition is simple because the flow is controlled. The parent can see the current data and change the children based on it. With the uncontrolled flow, the same condition would be difficult because the data would remain inside the flow component.

Why Controlled Components Are Often Preferred

Controlled components are often preferred because the parent can access and manage their state. This gives the application more ways to reuse the component and change its behavior.

The examples show several benefits:

  • A controlled form can validate a value while the user is typing.
  • A controlled modal can be opened or closed by a button in its parent.
  • A controlled onboarding flow can add or remove steps based on previously collected data.
  • A controlled component can be given a desired state directly during testing.

The component still has a clear responsibility. The modal displays its content, and the flow displays its current child. The parent handles the state and decides what should happen next.

Common Problems to Avoid

One problem is hiding state inside a component when its parent needs to use that state. An internally managed modal works until an outside button needs to control it. An internally managed onboarding flow works until the parent needs to change the steps based on collected data.

Another problem is expecting an uncontrolled form's values to be available before reading them from the DOM. In the uncontrolled example, the values are retrieved through refs when the form is submitted. If the application needs to validate every change, controlling the values with state gives it direct access to them.

Moving state to the parent also means the parent must pass the required values and functions back to the component. A controlled modal needs both shouldDisplay and onClose, while a controlled flow needs currentIndex and onNext. Without those props, the child no longer has the internal state needed to manage itself.

Summary

An uncontrolled component manages its state internally, and outside components have limited access to its current behavior. A form can read input values through refs on submission, a modal can store its own visibility, and an onboarding flow can keep its current index and collected data inside itself.

A controlled component moves that state outside and receives values and functions through props. This allows a parent to validate form values, control modal visibility, and change onboarding steps based on collected data. In most cases, this extra access makes controlled components more reusable, flexible, and easier to test.