DEV Community

Cover image for Uncontrolled components, Controlled components
Hari Bhandari
Hari Bhandari

Posted on

Uncontrolled components, Controlled components

Uncontrolled components are the components that manage their internal state independently. For eg; HTML <input /> manages its state, and is an uncontrolled component. If we provide value and manage/set the state it becomes controlled.

<input value={someValue} />

To clarify more, we can think of an uncontrolled component as a component whose state is not controlled/managed by the parent. While a controlled component is a component controlled by the parent through props.

<input /> // uncontrolled
Enter fullscreen mode Exit fullscreen mode
<input defaultValue='John Doe' /> // uncontrolled
Enter fullscreen mode Exit fullscreen mode
<input defaultValue='John Doe' onChange={doSomething} /> // uncontrolled
Enter fullscreen mode Exit fullscreen mode
<input value='John Doe' /> // controlled
Enter fullscreen mode Exit fullscreen mode
<input value={value} onChange={doSomething} /> // controlled
Enter fullscreen mode Exit fullscreen mode
<input value={value} defaultValue="John doe" onChange={doSomething} />
// controlled, but not a good practice. Console Warning: Decide between using a controlled or uncontrolled input element and remove one of these props. 
Enter fullscreen mode Exit fullscreen mode

The uncontrolled initial value of a component is usually prefixed with default.

Our component should handle both controlled and uncontrolled states gracefully. But keep in mind that An instance of a component can be either controlled or uncontrolled, not both simultaneously. If both controlled and uncontrolled states are passed, the component becomes Controlled component and the uncontrolled states are discarded.

Accordion Props

  • index?: number | number[] // The index or array of indices for open accordion panels, should be used along onChange to create a controlled accordion
  • onChange?: (index: number) => void // callback that is fired when an accordion item's open state is changed.

  • collapsible?: boolean //Whether or not all panels of an uncontrolled accordion can be closed. Defaults to false.

  • defaultIndex:?: number | number[] // default value for open panel's index in an uncontrolled accordion. If collapsible is set to true, without a defaultIndex no panels will initially be open. Otherwise, the first panel at index 0 will initially be open.

  • multiple?: boolean // In uncontrolled accordion, multiple panels can be opened at the same time. Defaults to false

  • readOnly?: boolean // Whether or not an uncontrolled accordion is read-only i.e. the user cannot toggle its state. Default false

Here, the Accordion will take props to handle both controlled and uncontrolled states: defaultIndex, multiple, collapsible, readOnly, and onChange are the uncontrolled state props, while index is the controlled state props. Keep in mind that if onChange is provided it should get fired for both controlled state and uncontrolled state changes.

AccordionItem should add disabled?: boolean props to disable an accordion item from user interaction. Defaults to false.

For more info regarding the props, please check this Reach UI Accordion docs

const Accordion = forwardRef(function (
  {
    children,
    as: Comp = "div",
    defaultIndex,
    index: controlledIndex,
    onChange,
    multiple = false,
    readOnly = false,
    collapsible = false,
    ...props
  }: AccordionProps,
  forwardedRef
) {
....

const AccordionItem = forwardRef(function (
  {
    children,
    as: Comp = "div",
    disabled = false,
    ...props
  }: AccordionItemProps,
  forwardedRef
) {
....
Enter fullscreen mode Exit fullscreen mode

Checkpoint: e20149f8f297add2fd03dc2064c961da5ea250e7

Handling uncontrolled state

First, we will handle the uncontrolled state (i.e. defaultIndex, multiple, collapsible, and not index, onChange)

For now, we will provide an index to each AccordionItem. This will be handled properly later in the Descendants section.

  <Accordion defaultIndex={[0, 1]} multiple collapsible>
    <Accordion.Item index={0}>   // <= index
      ....
    </Accordion.Item>
    <Accordion.Item index={1}>  // <= index
      ....
    </Accordion.Item>
      .... 
  </Accordion>
Enter fullscreen mode Exit fullscreen mode
const Accordion = (...) => {
  const [openPanels, setOpenPanels] = useState(() => {
    // Set initial open panel state according to multiple, collapsible props
  });

  const onAccordionItemClick = (index) => {
    setOpenPanels(prevOpenPanels => { // updater logic})
  }

  const context = {
    openPanels,
    onAccordionItemClick
  };

  return (
    <AccordionContext.Provider value={context}>
     ....
    </AccordionContext.Provider>
  );
};

const AccordionItem = ({ index, ...props }) => {
  const { openPanels } = useAccordionContext();

  const state = openPanels.includes(index) ? 'open' : 'closed'

  const context = {
    index,
    state,
  };

  return (
    <AccordionItemContext.Provider value={context}>
        ....
    </AccordionItemContext.Provider>
  );
};

const AccordionButton = () => {
  const { onAccordionItemClick } = useAccordionContext();
  const { index } = useAccordionItemContext();

  const handleTriggerClick = () => {
    onAccordionItemClick(index);
  };

  return (
    <Comp
      ....
      onClick={handleTriggerClick}
    >
      {children}
    </Comp>
  );
};

const AccordionPanel = (...) => {
  const { state } = useAccordionItemContext();

  return (
    <Comp
      ....
      hidden={state === 'closed' }
    >
      {children}
    </Comp>
  );
});
Enter fullscreen mode Exit fullscreen mode

Here,

  • The Accordion component manages the list of open panels and its updater function.
  • The list of open panels and its updater function is handled by the parent Accordion component.
  • AccordionButton updates the list of open panels in According using the updater function from the context
  • AccordionPanel hides the panel whose index is not included in the open panel list.

Checkpoint 4e43a301c322b8b2d7c6de5a6282700753353fa4

Controlled state

Handling the controlled state in our component is easy, as all the state handling would be done by the parent/consuming component.

const Accordion = forwardRef(function ({
+  index: controlledIndex,
+  onChange,
....
  const onAccordionItemClick = useCallback(
    (index: number) => {
+     onChange && onChange(index);

      setOpenPanels((prevOpenPanels) => {
       ...
  );

  const context = {
+    openPanels: controlledIndex ? controlledIndex : openPanels,
    .....
  };
Enter fullscreen mode Exit fullscreen mode

Here, the controlled state controlledIndex overrides the uncontrolled state in openPanels as it should. Regarding onChange, it doesn't determine if our component is controlled or uncontrolled. onChange can be passed with or without the controlled index prop. The purpose of the onControlled prop is to inform the parent component about the changed state.

function App() {
  const [openAccordions, setOpenAccordion] = useState([0, 2]);

  const handleAccordionChange = (index: number) => {
    setOpenAccordion((prev) => {
      if (prev.includes(index)) {
        return prev.filter((i) => i !== index);
      } else {
        return [...prev, index].sort();
      }
    });
  };

  return (
    <>
      <Accordion index={openAccordions} onChange={handleAccordionChange}>
        <Accordion.Item index={0}>
          <Accordion.Button>Button 1</Accordion.Button>
          <Accordion.Panel>Panel 1</Accordion.Panel>
        </Accordion.Item>
        ...
      </Accordion>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Checkpoint: ed4fcfc4d79c6b5148c1e7e4f484392f2abcaf23

If you check the code base of Radix UI, Reach UI they are using a custom hook (named useControlledState or similar) to handle both controlled and uncontrolled states.

The custom hook useControlledState will look something like this:

export function useControlledState(controlledValue, defaultValue) {
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const effectiveValue =
    controlledValue !== undefined ? controlledValue : uncontrolledValue;

  const set = ((newValue) => {
      if (!controlledValue) {
        setUncontrolledValue(newValue);
      }
    }
  return [effectiveValue, set];
}
Enter fullscreen mode Exit fullscreen mode

The signature of useControlledState is similar to useState. It takes an initial value (along with other things) and returns the current state and an updater function.
Here, for the controlled value, we are doing nothing and just returning it, while for the uncontrolled value, we are returning the state and state updater function.

The actual hook implementation looks like this:

export function useControlledState<T>({
  controlledValue,
  defaultValue,
  calledFrom = "A component",
}: UseControlledStateParams<T>): [T, React.Dispatch<React.SetStateAction<T>>] {
  const isControlled = controlledValue !== undefined;
  const wasControlledRef = useRef(isControlled);

  if (isDev()) {
    if (wasControlledRef.current && !isControlled) {
      console.warn(
        `${calledFrom} is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`
      );
    }
    if (!wasControlledRef.current && isControlled) {
      console.warn(
        `${calledFrom} is changing from uncontrolled to controlled. Components should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`
      );
    }
  }

  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const effectiveValue =
    controlledValue !== undefined ? controlledValue : uncontrolledValue;

  const set: React.Dispatch<React.SetStateAction<T>> = useCallback(
    (newValue) => {
      if (!controlledValue) {
        setUncontrolledValue(newValue);
      }
    },
    []
  );
  return [effectiveValue, set];
}
Enter fullscreen mode Exit fullscreen mode

Here, I have

  • wrapped the set state updater in useCallback to make it referentially equal when using it
  • log a warning if the component changes from controlled to uncontrolled and vice-versa in the dev environment. You may have noticed a similar kind of warning like A component is changing an uncontrolled input to be controlled..... For eg <input value={something} /> here if something changes from undefined to some string, <input /> changes from uncontrolled to controlled and this should not happen. So, we are logging a warning for this check in our hook. For more info regarding this, check this out

You can see a slightly different implementation of this hook in Adobe's React Aria/Spectrum here

Now, replace the useState in Accordion with this useControlledState hook.

Checkpoint: 52075e2eff2f4f9ffaf7bd865b24547df040346a

If you check other component libraries like Chakra UI, Radix UI, and others, useControlledState hook signature may be slightly different according to requirement:

export function useControlledState(controlledValue, defaultValue, onChange) {
  const [stateValue, setState] = useState(defaultValue);
  const effectiveValue = controlledValue !== undefined ? controlledValue : stateValue;

  const set = (newValue) => {
    if (onChange) {
      onChange(newValue);
    }
    setState(newValue);
  };

  return [effectiveValue, set];
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)