Seeing through JSX to understand React component Types
Why you shouldn't use React.FC
Here are the big takes right away:
- Don't use
React.FC
. - Explicitly type your props including permitted
children
types. - Prefer inferred return types, but
JSX.Element
if you have to.
To justify these claims, we will peek behind the JSX curtain to see what's really going on.
What is JSX really?
It is a lie.
JSX is a special kind of syntax which JavaScript can't run. You can see from the original JSX proposal that it was never intended to be something that can be run by JavaScript engines directly. Instead, it requires some transpiler to boil it down to a series of createElement
calls, which are just plain old JavaScript functions. Depending on your stack, you probably do this transpilation with either Babel
or TypeScript
.
Disclaimer: Neither Babel, TypeScript nor React have a monopoly on JSX; go take a look at Preact for example, if you haven't already.
To really understand this transpilation, let's inspect some examples:
I have lightly edited the Babel output for clarity but you can try these with this fancy link that encodes these examples in the Babel REPL
// Before Babel
function Hello(props) {
return <span hidden>{props.message}</span>;
}
// After Babel
function Hello(props) {
return React.createElement("span", { hidden: true }, props.message);
}
In this example the Hello
component demonstrates how primitive HTML elements are converted to a createElement
taking three arguments:
Args | Value |
HTML element name | "span" |
Attributes object | { hidden: true } |
Children | props.message |
But what if we have multiple children, or reference other React components:
// Before Babel
function MultiChildren() {
return (
<section>
<Hello message="I'm a child of the section" />
<Hello message="and me too" />
<Hello message="me three!" />
</section>
);
}
// After Babel
function MultiChildren() {
return React.createElement(
"section",
null,
React.createElement(Hello, { message: "I'm a child of the section" }),
React.createElement(Hello, { message: "and me too" }),
React.createElement(Hello, { message: "me three!" })
);
}
The MultiChildren
component is very similar but reveals a few more interesting features. Firstly, the first argument to createElement
can be a function reference to another React component, which is the Hello
component. Secondly, createElement
can take more than three arguments to accommodate multiple children. Finally, if no attributes are provided null
is used.
As an aside, have you ever wondered why you need to import React
into a JSX file, even when you don't reference it directly? Well now you can see, it is because you actually do! It is just hidden behind JSX transpilation. React
has to be in scope so that createElement
can run.
At this point we should have a pretty good idea of the sorts of arguments createElement
takes. Let's see what TypeScript has to say about it.
TypeScript
The following type definitions are from @types/react v17.0.11
createElement
has 8 overloads, but for our purposes this version is the most relevant, since it handles custom function components:
function createElement<P extends {}>(
type: FunctionComponent<P> | ComponentClass<P> | string,
props?: (Attributes & P) | null,
...children: ReactNode[]
): ReactElement<P>;
The P
generic type here corresponds to the props of the given component, which defaults to an empty object if none are provided. But other than that, there should be no surprises here. This matches exactly what we observed earlier:
- The first argument can be a string or a reference to a custom React component
- The second argument is an object containing attributes and props
- The third argument handles children (gathered from additional args as necessary)
It could be very tempting at this point to see the FunctionComponent
definition, and take that as the authority on how to type a component. But let's see what it really does:
interface FunctionComponent<P = {}> {
(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
propTypes?: WeakValidationMap<P>;
contextTypes?: ValidationMap<any>;
defaultProps?: Partial<P>;
displayName?: string;
}
type FC<P = {}> = FunctionComponent<P>;
I have a big issue with function type definitions that are both a function and also take additional key-values. But I'll save that for another post. For now, though, it should be clear there is a lot of stuff in there you don't need and probably will never use. We could dive into each of these, but let's just focus on PropsWithChildren
:
type PropsWithChildren<P> = P & { children?: ReactNode };
Remember that P
is the type of props passed to our component, but in this type definition, an optional children
parameter is added whether you wanted it or not.
In practice this silently ignores a particular bug which ought to be caught at compile time. To demonstrate this we just need a component that takes no children:
type GreetingProps = { name: string };
const Greeting: React.FC<GreetingProps> = ({ name }) => (
<span>Hello {name}!</span>
);
// Then somewhere else in our app:
<Greeting name="rupert">good morning</Greeting>;
In this example we define a component taking a single string prop name
, and then invoke it with the string rupert
but also pass the children string good morning
. This is a mistake. Our component doesn't take or use children, and if I wrote this code I would want my IDE to tell me right away, but because we are using React.FC
the actual type of props our Greeting
accepts has been broadened to:
{ name: string; children?: React.ReactNode }
So there's no compile time error. On the other hand if we simply didn't use React.FC
:
type GreetingProps = { name: string };
const Greeting = ({ name }: GreetingProps) => <span>Hello {name}!</span>;
// Then somewhere else in our app:
<Greeting name="rupert">good morning</Greeting>;
In this case, TypeScript will correctly tell us we've made a mistake. That's a good thing. Errors should be loud and discovered at the first opportunity.
Specific Children types
Hopefully, I've convinced you that if your component doesn't take children, you shouldn't use React.FC
, but what if your component does take children? OK, well, what type of children does your component take?
The most common type for children is ReactNode
. But if your component has to take children shouldn't you enforce that? By explicitly typing your children in the props type you define, you get that enforcement by TypeScript.
I've also seen components that must take multiple children, which we can type as ReactNode[]
. In this case, it is a type error to pass only a single child. I think that's great!
Or what about a component that takes a callback as its child:
<AuthGuard>(readPermission, writePermission) => {
// Custom render function based on user permissions
}</AuthGuard>
In this case, we can type children as
children: (readPermission: boolean, writePermission: boolean) => JSX.Element;
That's certainly something I want TypeScript to have tight control over.
Default Prop handling
One final critique I'd like to make of using React.FC
is how it forces us to type the function, rather than the inputs and outputs directly. This leads to some surprising behavior in the destructuring of default values:
type MessageProps = { message?: string };
// Here TypeScript correctly tells us that 'number' is not assignable to 'string'
const MessageWithoutReactFC = ({ message = 1 }: MessageProps) => {
return <span>{message}</span>;
};
// However in this case it allows a mismatching default value
const MessageWithReactFC: React.FC<MessageProps> = ({ message = 2 }) => {
return <span>{message}</span>;
};
TypeScript allows this in the React.FC
case because it is implicitly defining a new type on the fly after the props object has been destructured. Inside the body of MessageWithReactFC
the type of message
is string | number
. There is nothing strictly wrong with this, but it is very surprising behavior.
Return Types
It is up to you what your component returns, but naturally, it has to be something that React can render. Most of the time the type JSX.Element
is a good fit. Tailor this more specifically to your use case if possible.
Summary
Your type system exists to tell you when you make a mistake. It should be strict enough to exclude all unwanted values, but not so strict as to exclude wanted values. Take the time to think carefully and intentionally about the types your component takes in and the types it puts out. React.FC
is by design a very loose fit for very many kinds of components. We can and should be more specific.
- Don't use
React.FC
. - Explicitly type your props including permitted
children
types. - Prefer inferred return types, but
JSX.Element
if you have to.
Take care,
Rupert
Photo by Michael Dziedzic on Unsplash