A goal to strive for when using any framework or language is simplicity. Over time, it is the simpler application that is more maintainable, readable, testable, and performant. React is no exception, and we found that one of the best ways to manifest simplicity is by striving for functional purity in components, and by developing patterns that achieve this purity by default. Purity leads to more isolated and inherently simpler components, thereby bringing about a less braided and simpler system.
This is something we’ve thought a lot about at Asana — before we started using React, we had been building our in-house functionally reactive framework, Luna, since 2008. In iterating on this framework and building our web application, we’ve learned what worked and what caused long-term problems (read more). Through that, we’ve developed a series of overarching design principles that can be applied everywhere but particularly in React.
Immutable data representation
When your data representation is mutable, then you’ll find it very difficult to maintain simple components. Individual components will become more complex by detecting and handling the transition states when data changes, rather than handling this at a higher-level component dedicated to re-fetching the data. Additionally, in React, immutable structures often lead to better performance: when data changes in a mutable representation, you’ll likely need to rely on React’s virtual DOM to determine whether components should update; alternatively, in an immutable representation, you can use a basic strict equality check to determine whether an update should occur.
Any time we have deviated from this and used a mutable object in props, it has resulted in regret and refactoring.
See here for more general benefits of using immutable data structures.
Make liberal use of pure functions
A pure function is a function whose return value is solely determined by its input values, without dependence on global state or causing any side effects. In components, we often have complicated behavior that aids but is not directly tied to our rendering. Use pure helper functions to move this logic outside of the component, so that the component has fewer responsibilities and lower complexity. Additionally, this logic can be tested in an isolated way, and is re-usable in other components. If you notice common sets of these helper functions, then denote them as such by organizing them into sets of modules.
We’ve encountered two main classes of these which occur in almost all of our components:
- Data model helpers to derive a result from one or more objects (for example: to determine whether a user is currently on vacation)
- Mutation helpers to perform client- and server-side mutations in response to user actions (for example: to heart a task).
Use pure components, avoiding impure pitfalls
A pure component is a React component whose render function is pure (solely determined by props and state). The default behavior in React is to always re-render the entire component tree, even if props/state do not change. This is incredibly inefficient, and React suggests overriding shouldComponentUpdate to take advantage of pure render functions in the component’s lifecycle. This offers an enormous performance boost and increased simplicity, so you should consider doing this early-on.
When using pure components (overriding shouldComponentUpdate), there is no verification that you actually implement your components to be pure. So, it’s possible to accidentally write a component that is not pure, which will cause reactivity problems and show stale data to the user. We’ll discuss two of these “impure pitfalls.”
Globals
Using globals in a component means that the component is no longer pure, as it depends on data outside of props and state. If you rely on a global for rendering or in any of the component’s lifecycle methods, then you won’t achieve correctness and reactivity. We’ve found it immensely helpful to avoid using globals like the Document or Window, and instead pass these as props to the components which use them. We do this by creating a Services object, and by having each component declare in an interface which services it relies on. Through this, components can maintain purity and be independent of the global namespace.
Render Callbacks
A now-antipattern that used to be quite prevalent for us is a render callback:a function passed as a prop to a component, which allows that component to render something. A common use-case of a render callback was to allow a child to render something using data it did not receive in props. For example, if we wanted to have a generalized component that could render many types of child components, we would pass the component a callback to render the child.
Unfortunately, render callbacks are inherently impure because they can use whatever variables its function has closed on. So, because of our assumption of pure components, if any of the outside environment changes then our component would not re-render.
Let’s see this in a code snippet.
// Render callback anti-pattern
interface ParentProps {
someObject: SomeObject;
}
class ParentComponent extends PureComponent<ParentProps, {}> {
render() {
return React.createElement(ChildComponent, {
renderSomething: this._renderSomethingForIdx
});
}
private _renderSomethingForIdx(idx: number) {
return React.createElement(SomeOtherComponent, {
object: this.props.someObject,
idx: idx
});
}
}
interface ChildProps {
renderSomething: (idx: number) => React.ReactElement<any>;
}
class ChildComponent extends PureComponent<ChildProps, {}> {
render() {
// ... some other behavior ...
return this.props.renderSomething(123);
}
}
In this snippet, ParentComponent passes a render callback to ChildComponent, and that render callback uses someObject from props. Since ChildComponent uses this function for its rendering behavior, then it will not re-render if someObject changes.
Luckily, you can avoid using a render callback in one of three ways, depending on your constraints, and each allows us to keep our pure component assumption.
Alternative 1
Pass all information needed for rendering to the child component, and have that child render the component directly.
interface ParentProps {
someObject: SomeObject;
}
class ParentComponent extends PureComponent<ParentProps, {}> {
render() {
return React.createElement(ChildComponent, {
someObject: this.props.someObject
});
}
}
interface ChildProps {
idx: number;
someObject: SomeObject;
}
class ChildComponent extends PureComponent<ChildProps, {}> {
render() {
// ... some other behavior ...
return React.createElement(SomeOtherComponent, {
object: this.props.someObject,
idx: idx
});
}
}
We achieve the same rendered output by having ChildComponent render SomeOtherComponent itself. This works well if the additional props do not cause excess re-rendering, and do not break any contextual abstraction boundary in the component.
Alternative 2
Render the component in its entirety and pass that to the child component
interface ParentProps {
someObject: SomeObject;
}
class ParentComponent extends PureComponent<ParentProps, {}> {
render() {
return React.createElement(ChildComponent, {
somethingElement: this._renderSomethingElement()
});
}
private _renderSomethingElement() {
return React.createElement(SomeOtherComponent, {
object: this.props.someObject,
idx: 123 // Suppose this had access to the idx
});
}
}
interface ChildProps {
somethingElement: React.ReactElement<any>;
}
class ChildComponent extends PureComponent<ChildProps, {}> {
render() {
// ... some other behavior ...
return this.props.somethingElement;
}
}
In cases that ParentComponent has all of the information needed to render SomeOtherComponent, we can just pass it down as a prop to the ChildComponent.
Alternative 3
Render the component partially, pass the ReactElement to the child component, and use React’s cloneElement to inject the remaining props.
interface ParentProps {
someObject: SomeObject;
}
class ParentComponent extends PureComponent<ParentProps, {}> {
render() {
return React.createElement(ChildComponent, {
somethingElement: this._renderSomethingElement()
});
}
private _renderSomethingElement() {
return React.createElement(SomeOtherComponent, {
object: this.props.someObject,
idx: null // injected by ChildComponent
});
}
}
interface ChildProps {
somethingElement: React.ReactElement<any>;
}
class ChildComponent extends PureComponent<ChildProps, {}> {
render() {
// ... some other behavior ...
// Clone the passed-in element, and add in the remaining prop.
return React.cloneElement(this.props.somethingElement, {
idx: 123
});
}
}
This alternative is great for cases where neither ParentComponent nor ChildComponent have the full information needed to render SomeOtherComponent, so it shares the responsibility. While this may seem more complicated than the above two alternatives, it has a lot of desirable properties. In the next section, we’ll dig into a real world example to make it more concrete.
Divide components and use the injector pattern to maintain separation of concerns
Composition is an immensely useful pattern in React for achieving separation of concerns. Many great philosophies around this have developed, such as dividing components between presentational and container components. However, for some high-level components, such as a general component for drag-and-drop, composition necessitated either use of a render callback or added complexity. In such cases, we found the aforementioned injector pattern helpful.