Controlled / Uncontrolled React Components
Ever wondered how to author your own controlled or uncontrolled components?
Some Background #
If you're new to React application development, you might be asking yourself, "What are controlled and uncontrolled components, anyway?" I suggest taking a look at the docs linked above for a little extra context.
The need for controlled and uncontrolled components in React apps arises from the behavior of certain DOM elements such as <input>
, <textarea>
, and <select>
that by default maintain state (user input) within the DOM layer. Controlled components instead keep that state inside of React either in the component rendering the input, a parent component somewhere in the tree, or a flux store.
However this pattern can be extended to cover certain use cases that are unrelated to DOM state. For example, in a recent application I needed to create a nest-able Collapsible
component that supported two modes of operation: in some cases it needed to be controlled externally (expanded through user interaction with other areas of the app) and in other cases it could simply manage it's own state.
Inputs in React #
For input
s in React, it works like this.
To create an uncontrolled
input
: set adefaultValue
prop. In this case the React component will manage the value of its underlying DOM node within local component state. Implementation details aside, you can think of this as calls tosetState()
within the component to updatestate.value
which is assigned to the DOM input.To create a controlled
input
: set thevalue
andonChange()
props. In this case, React will always assign thevalue
prop as the input's value whenever thevalue
prop changes. When a user changes the input's value, theonChange()
callback will be called which must eventually result in a newvalue
prop being sent to the input. Consequently, ifonChange()
isn't wired up correctly, the input is effectively read-only; a user cannot change the value of the input because whenever the input is rendered it's value is set to thevalue
prop.
The General Pattern #
Fortunately it's trivial to author a component with this behavior. The key is to create a component interface that accepts one of two possible configurations of properties.
To create a controlled component, define the property you want to control as
defaultX
. When a component is instantiated and is given adefaultX
prop, it will begin with the value of that property and will manage its own state over the lifetime of the component (making calls tosetState()
in response to user interaction). This covers use case 1: the component does not need to be externally controlled and state can be local to the component.To create an uncontrolled component, define the property you want to control as
x
. When a component is instantiated and is given anx
prop and a callback to changex
, (e.g.toggleX()
, ifx
is a boolean) it will begin with the value of that prop. When a user interacts with the component, instead of asetState()
call within, the component must call the callbacktoggleX()
to request that state is externally updated. After that update propagates, the containing component should end up re-rendering and sending a newx
property to the controlled component.
The Collapsible Interface #
For the Collapsible
implementation, I was only dealing with a boolean property so I chose to use collapsed
/defaultCollapsed
and toggleCollapsed()
for my component interface.
When given a
defaultCollapsed
prop, the Collapsible will begin in the state declared by the prop but will manage it's own state over the lifetime of the component. Clicking on the childbutton
will trigger asetState()
that updates the internal component state.When given a
collapsed
boolean prop and atoggleCollapsed()
callback prop, the Collapsible will similarly begin in the state declared bycollapsed
but, when clicked, will only call thetoggleCollapsed()
callback. The expectation is thattoggleCollapsed()
will update state in an ancestor component which will cause the Collapsible to be re-rendered with a newcollapsed
property after the callback modifies state elsewhere in the application.
Implementation #
There is a dead-simple pattern within the component implementation that makes this work. The general idea is:
When the component is instantiated, set its state to the value of
x
that was passed in or the default value forx
. In the case of theCollapsible
, the default value ofdefaultCollapsed
isfalse
.When rendering, if the
x
prop is defined, then respect it (controlled), otherwise use the local component value inthis.state
(uncontrolled). This means that inCollapsible
'srender
method I determine the collapsed state as such:
let collapsed = this.props.hasOwnProperty('collapsed') ? this.props.collapsed : this.state.collapsed
With destructuring and default values, this becomes satisfyingly elegant:
// covers selecting the state for both the controlled and uncontrolled use cases
const {
collapsed = this.state.collapsed,
toggleCollapsed
} = this.props
The above says, "give me a binding called collapsed
whose value is this.props.collapsed
but, if that value is undefined
, use this.state.collapsed
instead".
Wrapping Up #
I hope you can see how simple and potentially useful it is to support both controlled and uncontrolled behaviors in your own components. I hope you have a clear understanding of why you might need to build components in this way and hopefully also how. Below I've included a the full source of Collapsible
in case you're curious - it's pretty short.
/**
* The Collapsible component is a higher order component that wraps a given
* component with collapsible behavior. The wrapped component is responsible
* for determining what to render based on the `collapsed` prop that will be
* sent to it.
*/
import invariant from 'invariant'
import { createElement, Component } from 'react'
import getDisplayName from 'recompose/getDisplayName'
import hoistStatics from 'hoist-non-react-statics'
import PropTypes from 'prop-types'
export default function collapsible(WrappedComponent) {
invariant(
typeof WrappedComponent == 'function',
`You must pass a component to the function returned by ` +
`collapsible. Instead received ${JSON.stringify(WrappedComponent)}`
)
const wrappedComponentName = getDisplayName(WrappedComponent)
const displayName = `Collapsible(${wrappedComponentName})`
class Collapsible extends Component {
static displayName = displayName
static WrappedComponent = WrappedComponent
static propTypes = {
onToggle: PropTypes.func,
collapsed: PropTypes.bool,
defaultCollapsed: PropTypes.bool
}
static defaultProps = {
onToggle: () => {},
collapsed: undefined,
defaultCollapsed: true
}
constructor(props, context) {
super(props, context)
this.state = {
collapsed: props.defaultCollapsed
}
}
render() {
const {
collapsed = this.state.collapsed, // the magic
defaultCollapsed,
...props
} = this.props
return createElement(WrappedComponent, {
...props,
collapsed,
toggleCollapsed: this.toggleCollapsed
})
}
toggleCollapsed = () => {
this.setState(({ collapsed }) => ({ collapsed: !collapsed }))
this.props.onToggle()
}
}
return hoistStatics(Collapsible, WrappedComponent)
}