React component quirks
Instantiation and invocation

What makes a React function component a component as opposed to only a function? How a function is instantiated or invoked determines this.

It would be easy to assume React function components are just functions. However, whether a function behaves as a component depends on how it whether it is invoked as a function or instantiated as a component, not on how it is defined. The act of instantiating a component through a React.createElement() call is what makes it one. In JSX, remember that <Child /> is a call to React.createElement(Child, [], []). In our example below, invoking a child component with function syntax means we’re treating it as a plain function that returns a ReactElement rather than as a component.

// 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 with function syntax:
const Consumer = () => Child();

What does it mean to behave as a component? It means there’s a component instance as a node in React’s component tree. Let’s first look at a component graph with these simple examples:

// 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 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 an author it’s meant to be instantiated as a component rather than invoked as a function. However, the further we get from the original component definition, the less obvious this is, and the potential for error increases.

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. In fact, this in itself is in 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.

Let’s explore what this means in a real-world example:

// Some dummy data:
 
const items = [{ id: 'abc123' }];
// 1. The following consumer is correct:
 
const renderItem = (item: { id: string }) => <ValueItem key={item.id} {...item} />;
 
const Consumer = () => <List items={items} renderItem={renderItem} />;
// 2. The following consumer is incorrect:
 
const Consumer = () => <List items={items} renderItem={ValueItem} />;

Although I labeled the above consumer examples correct and incorrect, that’s not the whole story. Whether the incorrect 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. Specifically, there are two things we can’t assume:

  1. Whether the ValueItem component containts lifecycle hooks — or whether it will contain them at some point in the future.
  2. Whether the List component instantiates the renderItem function with React.createElement() or simply invokes it as a function.

In this example, the List component we used above doesn’t invoke the renderItem function as a component when we look at its implementation:

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>
);

And the ValueItem component does rely on the React lifecycle through hooks.

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;
};

Looking at our incorrect consumer example, instead of having one hook per ValueItem instance, it has n hooks within a single List component instance. This is because there isn’t a ValueItem instance if ValueItem isn’t instantiated as a component.

// 1. Instantiated as a component:
 
   List
    | (n)
ValueItem -> useValue() // n component instances, each with one hook invocation
    |
   ...
// 2. Invoked as a function:
 
   List
    | (n)
    | -> useValue() // n hook invocations within one component instance
    |
   ...

What this means in a real project is, if we load our list of items incrementally or otherwise change the order of items within the list, we will have changed the numer or order of hook invocations. React relies on the order in which hooks are called during the render phase, so this is a serious issue.

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 about how functions will be outside the immediate chunk of implementation we’re working on.

Wherever we use a function component, as indicated through its capitalization, we need to ensure it will be instantiated as a component in order to prevent future bugs, regardless of whether it currently needs to be. As in the above example, this could be along the lines of wrapping a function component in an anonymous function to ensure the inner component is correctly instantiated when we pass it down to a different piece of code, as opposed to passing it directly.

or reach out to me at contact@ctidd.com