Using function components safely
Introduction
Anyone familiar with React could assume a function defined with a PascalCase name is inherently a component.
const Example = () => <p>I am a component!</p>;
However, whether a function is a component is based on how it is instantiated or invoked, and not how it is defined. <Example />
will instantiate a component, whereas Example()
will invoke the function without instantiating a component instance.
In JSX, <Example />
is transformed to React.createElement(Example, [], [])
. The act of calling React.createElement
on a function is what tells React to instantiate it as a component instance. Otherwise, a direct function call is transparent to React.
// Given this component:
const Child = () => <Something />;
// 1. We can instantiate the child component with JSX syntax:
const Consumer = () => <Child />;
// 2. We can invoke the child component incorrectly with function syntax:
const Consumer = () => Child();
A component instance will occupy a node in React’s component tree. Let’s look at a simple component tree:
// 1. Instantiated as a component:
Consumer
|
Child // <Child /> creates a component instance in the React component tree
|
Something
|
...
// 2. Invoked as a function:
Consumer
|
| // Child() is transparent as though its implementation were inlined into the consumer
|
Something
|
...
Of course, in the above examples, the capitalization of the Child
function should clearly indicate to a consumer that it’s intended to be used as a component. However, the further we get from the original component definition, the less obvious this intent can be. This creates a potential for error.
Specifically, if a function makes use of the React component lifecycle through hooks, failing to instantiate it as a component can easily violate the Rules of Hooks and introduce bugs. This failure to instantiate would introduce a violation of the rule, “Don’t call Hooks from regular JavaScript functions.” When we fail to instantiate a component, this is actually what we’ve done.
Problem
Let’s explore what this means in a real-world example:
// Some dummy data:
const items = [{ id: 'abc123' }];
// 1. The following consumer is safe:
const renderItem = (item: { id: string }) => <ValueItem key={item.id} {...item} />;
const Consumer = () => <List items={items} renderItem={renderItem} />;
// 2. The following consumer is unsafe:
const Consumer = () => <List items={items} renderItem={ValueItem} />;
Although I labeled the above consumers safe and unsafe, that’s not the whole story. Whether the unsafe consumer results in incorrect behavior depends on the implementation of the ValueItem
component and List
, which are not shown. However, when we’re writing the consumer, we should not assume the implementation details of these, as they could change in the future and break our implementation transitively.
This failure mode can occur due to future changes at at a distance. This means a good change in isolation can trigger a crash in existing bad code, which is a particularly dangerous failure mode if an application is not thoroughly integration tested.
Solution
To avoid this failure mode, there are two things we can’t assume:
- We can’t assume whether the
ValueItem
component containts lifecycle hooks — or whether it will contain them at some point in the future. - We can’t assume whether the
List
component instantiates the passedrenderItem
function withReact.createElement()
or simply invokes it as a function.
Let’s look at the code for the List
and ValueItem
components:
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
};
const List = <T extends {}>({ items, renderItem, keyItem }: ListProps<T>) => (
<ul>{items.map(item => renderItem(item))}</ul>
);
type ValueItemProps = {
id: string;
};
const ValueItem = ({ id }: ValueItemProps) => {
const v = useValue(id); // Custom hook that fetches a value by id
if (!v.ready) {
return <Spinner />;
}
if (v.error) {
return <Error error={v.error} />;
}
return v.value;
};
Given these implementation details, we see that List
doesn’t invoke the renderItem
function as a component, and the ValueItem
component does rely on the React lifecycle through hooks.
For the unsafe consumer, we can observe that instead of having one hook per ValueItem
instance, we would have have n hook instances directly within a single List
component instance. This is because we doesn’t actually instantiate ValueItem
instances. Depending on further implementation details of the consumer (e.g. updating the list of items on rerender), this is likely to result in an exception due to a hook violation.
In contrast, our safe consumer makes sure its renderItem
function instantiates the ValueItem
component. Then, it passes the renderItem
function to a non-local code path of the List
. By doing this, it doesn’t assume how List
will invoke the the renderItem
function.
Conclusion
The key point here isn’t to diligently check for this class of error along the entire code path each time we make an edit, but to avoid making assumptions and to code defensively. Wherever we use a function component, we need to ensure to instantiate it as a component. Most important, don’t try to be clever by passing a function component directly as a render prop; it might work today and break at a distance in the future.