React component quirks
Function component instantiation

What is function component as opposed to just a function in React?

Developers of React applications generally assume that a function defined with a PascalCase name is a component.

const Example = () => <p>I am a component!</p>;

However, whether a function is a component depends on how it whether it is instantiated as a component (e.g. <Example />). If it is instead invoked as a function (e.g. Example()), it is not a component.

In JSX, remember that <Example /> is a call to React.createElement(Example, [], []). The act of calling React.createElement on a function is what tells React to instantiate it as a component. Otherwise, it’s just a function that executes transparently 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 with function syntax:
const Consumer = () => Child();

What does it mean to be 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:

// 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 intended to be used as a component. 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. This failure to instantiate would introduct 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.

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 transitively.

Specifically, there are two things we can’t assume:

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

In the following example, the List component we used above doesn’t invoke the renderItem function as a component when we look at its implementation, yet the ValueItem component does rely on the React lifecycle through hooks:

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

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 that 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 used will be outside the immediate chunk of implementation we’re working on. Wherever we use a function component, we need to ensure it will be instantiated as a component — within the immediate code path where we are referencing it. This prevents future bugs if the component later depends on having its own lifecycle, regardless of whether the component currently does.

As with the example renderItem wrapper, we can safely wrap any component’s use within a locally-defined function that instantiates the component. Then, we can safely pass this function when we hand off to a non-local code path.

// Instantiate the component within a safe wrapper:
const renderItem = (item: { id: string }) => <ValueItem key={item.id} {...item} />;

// Pass only the safe wrapper to another code path:
const Consumer = () => <List items={items} renderItem={renderItem} />;