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 inputs in React, it works like this.
To create an uncontrolled
input: set adefaultValueprop. 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.valuewhich is assigned to the DOM input.To create a controlled
input: set thevalueandonChange()props. In this case, React will always assign thevalueprop as the input's value whenever thevalueprop changes. When a user changes the input's value, theonChange()callback will be called which must eventually result in a newvalueprop 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 thevalueprop.
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 adefaultXprop, 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 anxprop and a callback to changex, (e.g.toggleX(), ifxis 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 newxproperty 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
defaultCollapsedprop, 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 childbuttonwill trigger asetState()that updates the internal component state.When given a
collapsedboolean prop and atoggleCollapsed()callback prop, the Collapsible will similarly begin in the state declared bycollapsedbut, 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 newcollapsedproperty 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
xthat was passed in or the default value forx. In the case of theCollapsible, the default value ofdefaultCollapsedisfalse.When rendering, if the
xprop is defined, then respect it (controlled), otherwise use the local component value inthis.state(uncontrolled). This means that inCollapsible'srendermethod 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)
}