How to build a React component that is both controlled and uncontrolled (with examples)

How to build a React component that is both controlled and uncontrolled (with examples)

ยท

5 min read

When building a React component, the conventional wisdom is that it can be either controlled or uncontrolled. What if there is a way to get the best of the two worlds and create a component that supports both modes?

Controlled vs uncontrolled components in React

Let's first define our terms. Talking about controlled vs uncontrolled components is a bit of a misnomer. In reality, what we are really referring to is the component state.

Uncontrolled components

An uncontrolled component is a component where the component state is handled by the DOM. No React state management mechanism is used to control it.

For a very simple illustration of this idea, let's build a basic Accordion component.

const AccordionItem = ({ summary, children }) => {
  return (
    <details>
      <summary>{summary}</summary>
      {children}
    </details>
  );
};

const Accordion = () => {
  return (
    <div>
      <AccordionItem summary="Summary 1">Details 1</AccordionItem>
      <AccordionItem summary="Summary 2">Summary 2</AccordionItem>
    </div>
  );
};

Here we are not handling the opened / closed state of our accordion items with React - this is done entirely via the DOM. This makes our code relatively simple and straightforward. But there are clear limits to this implementation. For example, what if we want our accordion to have only one item opened at a time? For that to work - we need to implement a component with a controlled state.

Controlled components

A controlled component is a component where the component state is handled by React. The controlled piece of state is often passed down to the controlled component via a prop from the parent component.

Let's implement our accordion component with the new functionality:

const accordionItems = [
  { id: 1, summary: "Summary 1", details: "Details 1" },
  { id: 2, summary: "Summary 2", details: "Details 2" }
];

const AccordionItem = ({ summary, children, isOpen, onToggle }) => {
  return (
    <details
      open={isOpen}
      onClick={(e) => {
        e.preventDefault();
        onToggle();
      }}
    >
      <summary>{summary}</summary>
      {children}
    </details>
  );
};

const Accordion = () => {
  const [openedItem, setOpenedItem] = useState(null);

  return (
    <div>
      {accordionItems.map((item) => (
        <AccordionItem
          key={item.id}
          summary={item.summary}
          isOpen={openedItem === item.id}
          onToggle={() => setOpenedItem(openedItem !== item.id ? item.id : null)}
        >
            {item.details}
        </AccordionItem>
      ))}
    </div>
  );
};

As we can see, we have introduced two new props to our component in order to make sure we are able control its state from the parent - isOpened and onToggle. In this way - we can ensure that we only have one item opened at a time.

This works well but the downside is that, compared to the uncontrolled variant of the component, it requires us to manage the open / closed state of our accordion items every time we use this component. But maybe that is not what we want. What if sometimes we just want to define the initial state and not bother with managing it directly afterwards? Or, even simpler, maybe sometimes we want to leave the accordion items be fully uncontrolled.

How can we achieve this?

Hybrid components

To make a hybrid component that can support being both controlled and uncontrolled, we need to make the props that control it optional. What this means is that our AccordionItem component needs to work whether we pass isOpened and onToggle to it or not. This is how:

const AccordionItem = ({ children, summary, isOpen, onToggle }) => {
  const handleClick = onToggle
    ? (e) => {
        e.preventDefault();
        onToggle();
      }
    : undefined;

  const open = typeof isOpen !== "undefined" ? isOpen : undefined;

  return (
    <details open={open} onClick={handleClick}>
      <summary>{summary}</summary>
      {children}
    </details>
  );
};

With this implementation, our AccordionItem component is uncontrolled by default. But as soon as it receives the props isOpened and onToggle - it turns into a controlled component and we can manage its state in the parent. When using this pattern, it's important to guarantee there is a single source of truth for the component state. In our case, the uncontrolled mode is used only if the relevant props that would otherwise control the state are undefined.

Let's add an uncontrolled accordion item to our Accordion to see this in action.

import { useState } from "react";

const accordionItems = [
  { id: 1, summary: "Summary 1", details: "Details 1" },
  { id: 2, summary: "Summary 2", details: "Details 2" }
];

const Accordion = () => {
  const [openedItem, setOpenedItem] = useState(null);

  return (
    <div>
      {accordionItems.map((item) => (
        <AccordionItem
          key={item.id}
          summary={item.summary}
          isOpen={openedItem === item.id}
          onToggle={() => setOpenedItem(openedItem !== item.id ? item.id : null)}
        >
            {item.details}
        </AccordionItem>
      ))}
      <AccordionItem
        summary={"Summary of uncontrolled accordion item"}
        isOpen={true} // Optionally define the initial state of the uncontrolled accordion item
      >
        Uncontrolled accordion item
      </AccordionItem>
    </div>
  );
};

Now our uncontrolled item is unaffected by the state management mechanism, implemented for the rest of the accordion items. However, it expands and collapses as expected and we are even able to define its initial state, if needed.

Conclusion

It is possible to create a component that can support controlled and uncontrolled modes at the same time. Which mode will be used for a specific instance of the component depends entirely on the props that have been passed to it. This implementation gives us a lot of flexibility and arguably the best of both worlds.

The same pattern we used for our Accordion component can be applied to other elements that can be controlled by both the DOM and React, including but not limited to text inputs, checkboxes, radio buttons, selects, and more.

Keep reading to learn more about how to build flexible React components and how to document them directly in your code.

Happy coding! โœจ

Did you find this article valuable?

Support Iva Kop by becoming a sponsor. Any amount is appreciated!