How Children Types Work In React 18 And TypeScript 4
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:
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:
- JSX is a global namespace meaning you don't need to import it in order to use it.
- The
Element
type is a passthrough toReactElement
withany
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 likeReact.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 customchildren
arguments. You can also safely use this type for React elements passed as "regular" props. ReactElement
can be useful for readingprops
,type
, andkey
properties when parsingchildren
with one of theReact.Children
utilities, or perhaps building a React library. But you usually don't want it for thechildren
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
.