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
<input defaultValue='John Doe' /> // uncontrolled
<input defaultValue='John Doe' onChange={doSomething} /> // uncontrolled
<input value='John Doe' /> // controlled
<input value={value} onChange={doSomething} /> // controlled
<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
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
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>
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 inAccording
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,
.....
};
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>
</>
);
}
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.
- Radix UI, useControllableState
- Reach UI, useControlledState
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];
}
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];
}
Here, I have
- wrapped the
set
state updater inuseCallback
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 ifsomething
changes fromundefined
to somestring
,<input />
changes fromuncontrolled
tocontrolled
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];
}
Top comments (0)