Uncontrolled components, Controlled components
Tags: react
Published: 2024-01-23 04:26:59

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.

```html
<input /> // uncontrolled
```
```html
<input defaultValue='John Doe' /> // uncontrolled
```
```html
<input defaultValue='John Doe' onChange={doSomething} /> // uncontrolled
```
```html
<input value='John Doe' /> // controlled
```
```html
<input value={value} onChange={doSomething} /> // controlled
```
```html
<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. 
```

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](https://reach.tech/accordion/)

```jsx
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
) {
....
```
Checkpoint: [e20149f8f297add2fd03dc2064c961da5ea250e7](https://github.com/HariBhandari07/headless-accordion/commit/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.
```jsx
  <Accordion defaultIndex={[0, 1]} multiple collapsible>
    <Accordion.Item index={0}>   // <= index
      ....
    </Accordion.Item>
    <Accordion.Item index={1}>  // <= index
      ....
    </Accordion.Item>
      .... 
  </Accordion>
```

```jsx
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>
  );
});
```

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](https://github.com/HariBhandari07/headless-accordion/commit/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.
```diff
const Accordion = forwardRef(function ({
+  index: controlledIndex,
+  onChange,
....
  const onAccordionItemClick = useCallback(
    (index: number) => {
+     onChange && onChange(index);

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

  const context = {
+    openPanels: controlledIndex ? controlledIndex : openPanels,
    .....
  };
```
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.

```jsx
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>
    </>
  );
}
```
Checkpoint: [ed4fcfc4d79c6b5148c1e7e4f484392f2abcaf23](https://github.com/HariBhandari07/headless-accordion/commit/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. 
- Radix UI, [useControllableState](https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx)
- Reach UI, [useControlledState](https://github.com/reach/reach-ui/blob/dev/packages/utils/src/use-controlled-state.ts)

The custom hook `useControlledState` will look something like this:
```jsx
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];
}
```
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:
```jsx
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];
}
```
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](https://react.dev/reference/react-dom/components/input#im-getting-an-error-a-component-is-changing-an-uncontrolled-input-to-be-controlled)

You can see a slightly different implementation of this hook in Adobe's React Aria/Spectrum [here](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/utils/src/useControlledState.ts)

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

Checkpoint: [52075e2eff2f4f9ffaf7bd865b24547df040346a](https://github.com/HariBhandari07/headless-accordion/commit/01e56a64fb613b71cfcabb77a653d475b3ca4dfd)


If you check other component libraries like [Chakra UI](https://chakra-ui.com/docs/hooks/use-controllable), Radix UI, and others, `useControlledState` hook signature may be slightly different according to requirement:
```jsx
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];
}
```