Skip to content

Instantly share code, notes, and snippets.

@mikaelbr
Last active December 8, 2020 22:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mikaelbr/2bfb3acde7d2d484ae63e25fc26281a3 to your computer and use it in GitHub Desktop.
Save mikaelbr/2bfb3acde7d2d484ae63e25fc26281a3 to your computer and use it in GitHub Desktop.

Proper way to Type React Components

I was slow to embrace TypeScript. Not because I think TypeScript is bad, but more for my love of the dynamism of JavaScript. Surprisingly, I find myself looking back at the past projects and they've all been in TypeScript. I think it might be time to face the truth: I mostly code in TypeScript. But in my React projects, there has always been one thing bothering me: React.FC.

For some reason when I was learning TypeScript and React almost all blog posts, tutorials and examples were using React.FC to type up function components. And it makes sense in a way. You have a built-in type in the DefinitlyTyped React types that says React.FunctionComponent. Seems like a perfect fit! But alas, I think it is the wrong path to take and has some obvious drawbacks. I suggest typing properties directly as a function parameter instead of the React.FC helper type:

type MyComponentProps = { title: string };

// 👍 Like this..
function MyComponentFC(props: MyComponentProps) {}

// 👎 ...not like this
const MyComponent: React.FC<MyComponentProps> = (props) => {};

Let's investigate why!

All about that Expression

If you want to use React.FC as a type you have to annotate the function type itself. Not the parameters or the return value, but the function value. This means you need to have it as an expression. Either as an arrow function or as a regular function expression. I like function expressions, but usually prefer function declarations for top-level functions. There are a couple of reasons why: Prioritizing content through hoisting and direct exports.

Prioritizing content

When creating JavaScript/TypeScript modules I try to emphasize what is the most important content. Preferably, I try to optimize so that relevant content for understanding context and the main goal of the module is shown "above the fold" in your file. Meaning, when opening it in an editor or code explorer you see most of the important content. This means the main exported component with helper components and utils moved further down:

// 1. Setup variables. Works as expected
const BoundFooItem = partial(FooItem, { title: "Foo" });

// 2. Main export
export default function Foo() {}

// 3. Additional exports. Often children types that can be semantically grouped with main export.
// (Many prefer not to mix default with named exports, but a discussion for another time).
export function FooItem() {}

// 4. Helper components
function MyInternalComponent() {}

// 5. Utils
function add() {}
function partial() {}
function identity() {}

For this to work both FooItem and MyInternalComponent need to be hoist-able. Using function expression allows this type of hoisting under the right circumstances, but can also trigger Temporal Dead Zone (TDZ). Function declarations are a safer choice.

const BoundFooItem = partial(FooItem, { title: "Foo" });
// OOOPS: ReferenceError: Cannot access 'FooItem' before initialisation

export default function Foo() {}
export const FooItem = () => {};

Exporting directly

If you've ever worked with a codebase using React.FC you'll probably recognize this pattern:

const MyComponent: React.FC<{}> = () => {
  // Potentially lots of content here...
  // ...
};
export default MyComponent;

This is due to a combination of TypeScript and module grammar not supporting typed function expressions as the default export. This is more subjective, but I like grouping exporting and creation so it's easy to track what is the module's public API. Using default export with declaration doesn't support using React.FC.

// Clearer that MyComponent is the main API.
export default function MyComponent() {}

No Generics

Lack of hoisting without TDZ and not being able to export components directly are limitations we can work around. But there is one technical limitation that I struggled with when learning TypeScript with React and that makes it hard to recommend using React.FC as a general-purpose typing style for React Components: Generics.

There are often cases when you create reusable components like drop-downs or lists where you don't exactly know what type the passed items are. This is the case for generics. React and TypeScript supports generics, but the grammar of TypeScript does not allow you to do generics when using React.FC:

type MyDropDownProps<T> = {
 items: T[];
 itemToString(item: T): string;
 onSelected(item: T): void;
};

// Neither of these examples are valid
const MyDropDown: React.FC<MyDropDownProps> = (props) => {};
const MyDropDown: React.FC<MyDropDownProps<T>> = <T>(props) => {};
const MyDropDown: React.FC<MyDropDownProps<T>> = (props) => {};
const MyDropDown<T>: React.FC<MyDropDownProps<T>> = (props) => {};
// How?? No direct way when using React.FC

To properly use generics with MyDropDown we would have to use types directly as typed parameters:

type MyDropDownProps<T> = {
  items: T[];
  itemToString(item: T): string;
  onSelected(item: T): void;
};

// Valid code
function MyDropDown<T>(props: MyDropDownProps<T>) {}

By typing parameters directly we use TypeScript the same way as we would do with any other function. No special case for React components. This means we can use our existing knowledge from other codebases with TypeScript. The one thing we lack here is typing the returned value from our component. No worries, though! TypeScript is more than capable of inferring the return type on its own. In the cases where you need to be explicit, you can add return type manually:

function MyDropDown<T>(props: MyDropDownProps<T>): JSX.Element {}

We gon' take Children

Talking about JSX.Elements. If we use the proposed solution from above, how do we handle children? Because this won't work:

type MyComponentProps = { className: string };

// Does not work.
function MyComponent({ className, children }: MyComponentProps) {
  // children is not typed and TypeScript complains
  return <div className={className}>{children}</div>;
}

But using React.FC works with children:

// Works as expected:
const MyComponent: React.FC<MyComponentProps> = function ({
  className,
  children,
}) {
  // children is typed
  return <div className={className}>{children}</div>;
};

This is because React.FC adds children property to the props type. This seems like a convenience, but it is actually a bad thing! By using React.FC it always looks like you take children as properties:

type MyComponentProps = { title: string };
const MyComponent: React.FC<MyComponentProps> = function ({ title }) {
  // children is not typed and TypeScript complains
  return <div>{title}</div>;
};

// TypeScript Allows this, but it is false positive in a sense.
const myValue = <MyComponent title="Hello">My children</MyComponent>;

// This is more correct
function MyComponentCorrect({ title }: MyComponentProps) {
  return <div>{title}</div>;
}

const myValueCorrect = (
  <MyComponentCorrect title="Hello">My children</MyComponentCorrect>
);
// Error:
// Type '{ children: string; title: string; }' is not assignable to type 'IntrinsicAttributes & MyComponentProps'.
//   Property 'children' does not exist on type 'IntrinsicAttributes & MyComponentProps'.

Using prop types directly on the parameter offers more flexibility and correctness without allowing false positives. But how do you type children, then? You can use two different ways: either manually or using helper types from React.

// Manually
type MyComponentProps = {
  title: string;

  // ReactNode is all allowed children types including arrays, fragments, scalar values, etc.
  children: React.ReactNode;
};
function MyComponentCorrect({ title, children }: MyComponentProps) {}

If you find it difficult remembering what type to use for children you can use a convenience helper from the React types:

type MyComponentProps = React.PropsWithChildren<{
  title: string;
}>;
function MyComponentCorrect({ title, children }: MyComponentProps) {}

Which does the same thing. PropsWithChildren is defined as an intersection type, much like React.FC:

type PropsWithChildren<P> = P & { children?: ReactNode };
// (note: optional type is redundant as ReactNode can be undefined)

Using props directly on parameters allow you to more correctly type components and avoid false positives while also being more flexible.

Conclusion

Starting up with TypeScript and React the React.FC was confusing to me. And I'm honestly not sure why it is so widely used. I think newcomers should see established idiomatic practices of TypeScript, not odd practices tied to specific libraries. The default practice should allow for common features such as generics without being confusing.

I suggest for being consistent in typing, better allowing for hoisting and direct default exports, avoiding false positives for children and support for generics, to always type React component props directly as function parameters. This way you can use TypeScript as with any other function and also be more correct than when using React.FC. As an added benefit, you can easier switch between component-based libraries such as preact (et.al.).

When using typed parameters instead of React.FC, you might have to explicitly type return values as JSX.Element, but in almost all practical cases this is something TypeScript can infer. Some might also say a drawback can be having to explicitly type the parameters, making function signature harder to read. I don't agree, but I can see how some people think so. I think the pros massively outweigh the few cons.

This isn't a new opinion, but still, I see a lot of codebases and tutorials where React.FC is the de-facto standard. So I think it is a message that that bears repeating. Is it, though, worth rewriting/codemodding your entire codebase? Probably not, but I think it's definitely worth using parameter types directly for all new components here on out and gradually removing React.FC from your codebase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment