How Children Types Work In React 18 And TypeScript 4

How Children Types Work In React 18 And TypeScript 4
Photo by Aaron Burden

One of the coolest features of React is that everything is "just JavaScript" (or TypeScript!) so the library mostly doesn't care what you provide as children as long as it can be rendered to the UI. We even can have functions as children (aka, render props)!

But I said you can render "mostly" anything. There are some exceptions and this presents a challenge for defining accurate type definitions for React children.

React type definitions

The React type definitions (@types/react) are maintained separately from the core react library by the TypeScript community. However, the react team works closely with the community to ensure types are accurate so that TypeScript can help developers use React as correctly as possible. (the React team even included a great react types upgrade guide with the React 18 release).

These types are essential for developing my React + TypeScript projects. However, one thing I have frequently gotten tripped up on is how children types are defined and intended to be used.

What types can be react children

The react docs explain the following types can be used as children:

  • string literals (also includes numbers which are coerced to HTML strings)
  • JSX children (aka, nested react components)
  • functions (for custom components that accept functions)
  • booleans, null, and undefined (ignored and not rendered)

@types/react provides a couple different options that seem could be used to fulfill this type definition: ReactNode, ReactElement, and JSX.Element. However, these types are slightly different. Which one should we use for adding types to children in our custom components?

When to use ReactNode, ReactElement, or JSX.Element

A common scenario when writing custom components is to allow that component to accept children as a prop. React treats children with just a little bit of magic that allows for composing React elements into more complex components.

Let's look at an example and see how we might want to add types to it:

// myComponent.tsx
import React from 'react'

export function MyComponent({ children }: { 
  children: ??? // ReactNode or ReactElement or JSX.Element?
}) {
  return <div className="some-class">{children}</div>
}

// app.tsx
import { MyComponent } from './myComponent'

...

return (
  <MyComponent>
    <p>hello!</p> // react passes this to MyComponent as `children`
  </MyComponent>
)

Here we arrive at the problem: what to put in place of children: ???? When do you use ReactNode, ReactElement, or JSX.Element?

In TypeScript, when I'm not sure what type to use, my first step is to look at the type definition. Code editors make this easy with "Go to definition" (in VSCode: ctrl+click/cmd+click).

When we do this for ReactNode, ReactElement, and JSX.Element we see these types:

type ReactNode =
  | ReactElement
  | string
  | number
  | ReactFragment
  | ReactPortal
  | boolean
  | null
  | undefined;

interface ReactElement<
  P = any,
  T extends string | JSXElementConstructor<any> =
    | string
    | JSXElementConstructor<any>
> {
  type: T;
  props: P;
  key: Key | null;
}

declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> {}
  }
}

These types look intimidating! But this is pretty typical for type definitions for a library like React since it's so flexible in what it can do. However, even with the apparent complexity, if we break down each type, we'll find they make a lot of sense. So let's dive in starting with ReactNode.

What is the ReactNode type

The first thing that sticks out are some of the familiar types the React docs talked about. Notably: string, number, boolean, null, and undefined.

We will look at ReactElement in a moment but what about ReactFragment and ReactPortal?

Turning to the @types/react-dom types, we see that ReactPortal is the return type of createPortal. This is nice as the types are telling us a ReactNode can be the React element created by calling createPortal.

ReactFragment is defined as:

type ReactFragment = Iterable<ReactNode>;

Iterable is a type built-in to TypeScript. A common type of Iterable is an array so one way to think about this is that ReactNode can also be ReactNode[]. This is neat because the ReactNode type is referencing itself. This means you can have nested arrays, or a mixture of arrays and other node types (eg, string or number) and React/TypeScript will be okay with it.

Finally, ReactElement has a bit more going on, and is one of our contenders for defining types for children props. So let's look a bit closer.

What is the ReactElement type

Ignoring the generics for a moment, ReactElement is an interface with three keys: type, props, and key.

These keys represent the attributes you would read on a child component when using a utility like React.children.forEach:

function MyComponent({ children }: { children: React.ReactElement }) {
  React.Children.forEach(children, (child) => {
    console.log(child.props) 
    // { className: "hello-class", children: "Hello" }
    
    console.log(child.type) 
    // "div"
    
    console.log(child.key) 
    // "some-key"
  })
}

...

<MyComponent>
  <div key="some-key" className="hello-class">
    Hello
  </div>
</MyComponent>

Can ReactElement enforce children of a certain type

ReactElement's generics (in theory) should allow us to provide a type definition for what type props and type should be. This would give us some great improved type safety because we could enforce only React components of a certain type can be used as children:

type ChildProps = {
  vegetables: string[]  
}

function MyComponent({ children }: { 
  children: React.ReactElement<ChildProps>
}) {
  React.Children.forEach(children, (child) => {
    console.log(child.props.vegetables) // no error!
    console.log(child.props.fruit)
    // error! Property 'fruit' does not exist on type 'ChildProps'
  })
}

However, this remains a theory since TypeScript today is not yet capable of checking the type of a component element passed as children. This limitation means we don't get a type error when we expect it:

function ChildComponent(props: ChildProps) {
  ...
}

type OtherChildProps = {
  somethingBesidesVegetables: string[]
}

function OtherChildComponent(props: OtherChildProps) {
  return ...
}

...

<MyComponent>
  // no error (expected!)
  <ChildComponent />
  
  // no error even though OtherChildProps doesn't match ChildProps
  // expected by MyComponent
  <MyChildComponent /> 
</MyComponent>

In practice, this means the best we can safely achieve with ReactElement is ReactElement<any>. This also aligns with React's documentation which explains that children is an opaque data structure meaning React doesn't really intend for you to be able to fully introspect it.

As we discussed earlier,ReactElement is included as part of the union definition for ReactNode. This means ReactNode is a superset of ReactElement: ReactNode can be ReactElement or one of the other specified union types. And since we can't safely refine the type further than ReactElement<any> (the same generic value used by ReactNode), there's little reason to use ReactElement.

ReactElement is also more restrictive than ReactNode in what can be passed as children. Where ReactNode allows primitives like string and boolean, ReactElement must be a singular (or multiple with ReactElement[]) React component element:

<MyComponent>
  // error! 'MyComponent' components don't accept text as child elements.
  hello
</MyComponent>

This can be nice for a few narrow use cases (ie, the React library type definitions uses ReactElement commonly for various React utilities). But this is probably too restrictive for most app components.

What is the JSX.Element type

By default, TypeScript will infer based on usage and assign a React element the JSX.Element type:

TypeScript playground example showing that TypeScript assigns the JSX.Element type to JSX elements by default

Looking again at the JSX.Element type definition we see:

declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> {}
  }
}

There's two things that stick out to me:

  1. JSX is a global namespace meaning you don't need to import it in order to use it.
  2. The Element type is a passthrough to ReactElement with any supplied as generic arguments

This means JSX.Element has many of the same properties as ReactElement including its limitations. What could be interpreted as a slight improvement is the generics are pre-filled to any, preventing consumers from thinking children types can properly be restricted (even though TypeScript doesn't support this). However, since ReactNode has the same pre-filled-to-any behavior, and is more flexible, it's hard to recommend using JSX.Element.

Conclusion

It can seem confusing at first to have multiple types that seem to accomplish the same thing. However, after reviewing the types we can see they have slightly different purposes:

  • ReactElement is useful for parsing children with utilities like React.Children.forEach
  • JSX.Element is the type that TypeScript infers and applies as a default to JSX React elements.
  • ReactNode is used for most other cases

So the answer to "when should you use ReactNode, ReactElement, or JSX.Element" is this:

  • If you're an app developer, ReactNode will usually be the preferred option for custom components that accept custom children arguments. You can also safely use this type for React elements passed as "regular" props.
  • ReactElement can be useful for reading props, type, and key properties when parsing children with one of the React.Children utilities, or perhaps building a React library. But you usually don't want it for the children props on your custom components.
  • JSX.Element is a default inferred type applied by TypeScript and is not usually what you want to define for your own components.

As a developer it's important to use the right tool (or type in this case) for the job. While you may have a use case for ReactElement or JSX.Element, if you're not sure which to use, stick with ReactNode.