- Introduction
- What is Component State?
- Component State is a Chimera
- State Hoisting
- State Hoisting Consequences
- Conclusion
All the current component-based frameworks have a notion of component state. A careful examination of the nature of component state exposes component state as a chimera. This truth has been concealed by the employment of state hoisting. Superficially, state hoisting seems like a resolution to component state's limitations. In reality, state hoisting is the source of much of the frustration with today's frameworks.
Component state is state declared by a component and owned by each instance of this component.
Component state is tied to an instance:
- The state lifecycle mirrors the instance lifecycle: it's created when the instance is mounted and destroyed when the instance is unmounted.
- Access to the state is limited to the instance:
- only the instance itself has direct access
- indirect access can be provided to descendants
Let's look at an example in React:
// pulled from https://reactjs.org/docs/forms.html
class Reservation extends React.Component {
constructor(props) {
super(props);
this.state = {
isGoing: true,
numberOfGuests: 2
};
this.handleInputChange = this.handleInputChange.bind(this);
}
handleInputChange(event) {
const target = event.target;
const value = target.type === "checkbox" ? target.checked : target.value;
const name = target.name;
this.setState({
[name]: value
});
}
render() {
return (
<form>
<label>
Is going:
<input
name="isGoing"
type="checkbox"
checked={this.state.isGoing}
onChange={this.handleInputChange}
/>
</label>
<br />
<label>
Number of guests:
<input
name="numberOfGuests"
type="number"
value={this.state.numberOfGuests}
onChange={this.handleInputChange}
/>
</label>
</form>
);
}
}The Reservation component declares a form to make a reservation. This form declares the following component state:
this.state = {
isGoing: true,
numberOfGuests: 2
};The nature of component state is ill-fitted to most state.
State often has broad implications. Let's again consider our Reservation form. Let's say that above the form we want to display the expected cost for attending the event with the specified number of guests. The problem? The CostEstimator component needs access to the specified number of guests, but that state is imprisoned within the Reservation component.
In practice, the state and instance lifecycles commonly diverge. Let's consider our reservation application. The user is carefully filling out the form. As they're filling out the form, they decide to convince a buddy to join them. The user navigates to a different page with more details of the event to make a more compelling case to the aforementioned acquaintance. The user convinces their buddy, and then goes back to the reservation form. The problem? The form's reset to its initial state, and the user has to fill it out again. When the user navigated to the events details page, the component was unmounted, and its state was killed.
The workaround employed whenever we discover state has broader implications or state and instance lifecycles diverge is state hoisting. State hoisting is the movement of state to an ancestor.
We want both the CostEstimator and Reservation components to have access to numberOfGuests. As mentioned above, only the instance itself has direct access to component state, but indirect access can be provided to descendants. If we move numberOfGuests to a common ancestor, then both the CostEstimator and Reservation components can be given access to numberOfGuests.
Let's take a look at the code for this:
class ReservationManagement extends React.Component {
constructor(props) {
super(props);
this.state = {
numberOfGuests: 2
};
this.streamNumberOfGuests = this.streamNumberOfGuests.bind(this);
}
streamNumberOfGuests(event) {
this.setState({
numberOfGuests: event.target.value
});
}
render() {
return (
<div>
<CostEstimator numberOfGuests={this.state.numberOfGuest} />
<Reservation
numberOfGuests={this.state.numberOfGuest}
streamNumberOfGuests={this.streamNumberOfGuests}
/>
</div>
);
}
}
class Reservation extends React.Component {
constructor(props) {
super(props);
this.state = {
isGoing: true
};
this.streamIsGoing = this.streamIsGoing.bind(this);
}
streamIsGoing(event) {
this.setState({
isGoing: event.target.checked
});
}
render() {
return (
<form>
<label>
Is going:
<input
name="isGoing"
type="checkbox"
checked={this.state.isGoing}
onChange={this.streamIsGoing}
/>
</label>
<br />
<label>
Number of guests:
<input
name="numberOfGuests"
type="number"
value={this.props.numberOfGuests}
onChange={this.props.streamNumberOfGuests}
/>
</label>
</form>
);
}
}The state lifecycle mirrors the instance lifecycle. If you want to extend the state lifecycle you have to move the state to an ancestor with a longer lifecycle. The limit of state hoisting is a global store tied to the root of your application. Since the application is always mounted, the state lives as you move throughout the app.
At first glance, state hoisting may seem like a fine way to deal with the limitations of component state. At a closer inspection, however, we see that state hoisting has a bevy of harmful ramifications.
A component should encapsulate a piece of an interface, and compose well with other such pieces.
Hoisting breaks encapsulation by leaking state management details. After hoisting numberOfGuest, we cannot understand the Reservation component in isolation. We must always consider it alongside the ReservationManagement component.
Hoisting hinders composition by encoding assumptions about component usage. After hoisting numberOfGuest, we can only use Reservation in a context where an ancestor manages numberOfGuest.
State hoisting obscures the state that impacts a given instance. We have to consider the state owned by the instance plus the state owned by all of its ancestors. Further, we have to consider how ancestors' state is transformed as it travels from ancestors to the instance in question!
React has popularized thinking about your user interface as a function between state and view: state => view. Each state corresponds to a view. This is an accurate way to think about declarative user interfaces. However, it is an incomplete model.
The correct mathematical model of a declarative user interface is a state machine. A state machine is completely described by a set of states and the rules to transition between states. And yes, each state corresponds to a view. A view encodes transition rules. A transition rule consists of an event (e.g a user clicks a button) and the corresponding state update (e.g we increase count by 1).
We have already seen how state hoisting obscures the value of state by creating many cascading bubbles of state. Additionally, state hoisting complicates reasoning about transitions by fostering transition rule disintegration: distance between the two elements of a transition rule (i.e update and event).
Let's consider the transition rule for numberOfGuests. The event that triggers an update of numberOfGuests is a change to the corresponding input located in the Reservation component. The update triggered on that event is described in ReservationManagement. This disintegration makes the transition more difficult to understand, we again have to reason about ReservationManagement and Reservation simultaneously.
State hoisting promotes inefficient reconciliation.
The more we hoist state:
- the bigger the subtrees we have to reconcile
- the smaller the ratio: view that needs to be updated / view that was diffed
In summary, a minor update with minor impact forces reconciliation of large subtrees.
The shackles of component state force state and state updates to travel along paths. These paths incur memory overhead. This is simple to see, we hold the state and props of every instance in memory. The ReservationManagement component stores numberOfGuests even though it never makes any real use of its value.
All the negative consequences of hoisting state get more dramatic the more we hoist:
- Further subversion of the component model
- Further obscured state
- Further transition rule disintegration
- Even more inefficient reconciliation
- Greater memory overhead
The Reservation example is really just a toy example. But consider the principles discussed above for a large form with several sections as is typical in an enterprise application.
Component state is an illusion. We pay a very steep price to maintain this illusion.