1. Avoid using React.PropTypes to define component’s props if you don’t have a specific use-case

Fully leveraging TypeScript’s capabilities to make your code cleaner and simplistic is the first rule when using it in combination with React.

Components' accepted props should be defined either via interfaces or type aliases.

If your next question is “Which one should be used when?”, the answer is that they can be used interchangeably, as they are very similar, but there should exist consistency across your codebase. (See official docs about Differences Between Type Aliases and Interfaces for more details)

A recommended rule of thumb could be to use interfaces until you actually need a type. That would be translated into making use of interfaces for defining components' prop types and making use of types to alias primitive values or create unions from other types.

type ButtonVariant = "primary" | "secondary";

interface ButtonProps {
  variant: ButtonVariant;

  color?: string;
}

In case you might make use of some already built-in types, for example, the ones existent in React, like React.PropsWithChildren type, then you might need to use the type-based definition approach for specifying the props contract of a component so that you can pass it along to the generic built-in type. You’ll see some examples later.

Going back to React.PropTypes, it is worth mentioning that TypeScript validates types at compile time, whereas PropTypes are checked at runtime. As there’s no value to maintain both variants at the same time in a React app, you should only make use of React.PropTypes feature when you have a specific use-case for it. That chould happen when your components should interact with external data, and type-checking should be done at runtime. Here are a few examples:

  • Publishing a package such as a component library that will be used by plain JavaScript;

  • Accepting and passing along external input such as results from an API call and rendering components based on them;

  • Using data from a library that may not have adequate or accurate typings, if any.

Other than that, remember that TypeScript serves a much greater role than prop-types. It offers intelligent code suggestions in your IDE, makes your code cleaner, and shows its strengths by helping you to avoid potential bugs while writing the code.

2. Avoid using the old defaultProps property on function components

The property is already marked for deprecation. The way of defining a component’s default values for the defined props is to make use of JavaScript’s native destructuring and default values capabilities:

Prefer:

interface ButtonProps {
  color?: string;
}

function Button({ color = "blue" }: ButtonProps) {
  /* ... */
}

Avoid:

type ButtonProps = { color: string } & typeof defaultProps;

function Button(props: ButtonProps) {
  /* ... */
}

const defaultProps = { color: "blue" };

Button.defaultProps = defaultProps;

3. Do not waste time specifying automatically inferred types

In TypeScript, there are several places where type inference is used to provide type information when there is no explicit type annotation. For example, in this simple line of code

const x = 3;

hovering over x in your IDE will tell you automatically that it is of type number.

Following this feature, we should say that sometimes passing a type to any variable or function could be completely useless.

Avoid:

import { useState } from "react";

const Example = () => {
  const [count, setCount] = useState<number>(0);

  // const [count, setCount] = useState(0); - type is automatically inferred

  return <div>Example</div>;
};

4. Make use of React’s built-in helper types to extend native HTML elements

Let’s continue with approaching examples on the same Button case. Supposing that we’d have to implement a component that needs to be clickable and accept the custom specification of its inner textual content. Going classically, we’d start with something like this:

interface ButtonProps {
  name: string;

  onClick: () => void;

  children: React.ReactNode;
}

function Button({ children, onClick }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>;
}

One of the first built-in helpers with would be handy in this situation is React.PropsWithChildren, which would automatically add the children prop to the component.

In practice, the definition of it looks like this:

type PropsWithChildren<P> = P & { children?: ReactNode };

Based on it, our code now should look like this:

type ButtonProps = {
  name: string;

  onClick: () => void;
};

function Button({ children, onClick }: React.PropsWithChildren<ButtonProps>) {
  return <button onClick={onClick}>{children}</button>;
}

When you pass PropsWithChildren to your component prop FooProps, you get the children prop internally typed.

In most cases, this is the recommended way to go about typing the children prop because it requires less boilerplate, and the children prop is implicitly typed.

Our component now looks a bit better, but there’s still room for other improvements. onClick is something specific to a native <button> HTML element. If we think of a large codebase (e.g. a UI components library) where a component like this would scale and receive more and more props based on its usage, the code won’t look so pretty and clean, as you might need to re-specify native html attributes as props:

type ButtonProps = {
  name: string;

  type: "submit" | "button" | "reset" | undefined;

  disabled: boolean;

  onClick: () => void;

  //... more and more props
};

function Button({ children, onClick }: React.PropsWithChildren<ButtonProps>) {
  return <button onClick={onClick}>{children}</button>;
}

Fortunately, TypeScript has a built-in utility designed specifically for this purpose. It’s called ComponentPropsWithoutRef and it is a generic type that supplies props for built-in React handlers and native HTML attributes. All you need to do is to pass a native element’s name to it as the used template and that would allow the extension of the native attributes specified as props.

type ButtonProps = React.ComponentPropsWithoutRef<"button">;

const Button = ({ children, onClick, type }: ButtonProps) => (
    <button onClick={onClick} type={type}>
      {children}
    </button>
);

This way, we have less, cleaner code. In case you need to extend the type that encapsulates the native props, that’s possible too. One way of doing that is via an interface:

type ButtonVariant = "primary" | "secondary";

interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> {
  variant: ButtonVariant;
}

const Button = ({ children, onClick, type }: ButtonProps) => (
    <button onClick={onClick} type={type}>
      {children}
    </button>
);

Another good observation that is worth to be said here is that a practice is to try to name custom props for components that serve a similar purpose to the native existent ones different than the existing native attributes.

5. Make use of polymorphic components to enhance reusability and accessibility

Generally speaking about polymorphism, it represents the ability of a variable, function, or object to take on multiple forms.

In our scenario, consider a general-purpose <Container> that would act as a Higher Order Component to encapsulate a piece of reusable behavior. For example, it could attach a specific set of styles to a <div>. You may want to be able to re-use this kind of <Container> in situations where maybe the returned element should be of type <section> or <aside>.

The very first intention would be to create some clones of the existing component and adjust it based on the needs. Rather than this approach, we should better create a polymorphic component. This approach is very often used in open-source component libraries. Consider this example from Chakra UI:

<Box as="button" borderRadius="md" bg="tomato" color="white" px={4} h={8}>
  Button text
</Box>

The polymorphic behavior in this example is enabled by a prop called as. The <Box> container component will now render as a native <button> element in the DOM. Changing the value of as to be h2 means that in the DOM, the element will be rendered as a native <h2>.

A very similar approach is encountered in MUI, just that the name of the polymorphic prop is component.

But how does that happen behind the scene? Let’s go back to our example with the general purpose <Container>. The code for our requirements would look like this:

import styles from "Container.module.css";

type ContainerProps = {
  as: "div" | "section" | "aside";
};

const Container = ({
  as: Component = "div",
  children,
}: React.PropsWithChildren<ContainerProps>) => (
  <Component className={styles.container}>{children}</Component>
);

Note that here the polymorphic prop as is similar to Chakra UI’s. This is the prop we expose to control the render element of the polymorphic component. Secondly, note that the as prop isn’t rendered directly. The following code won’t work:

const Container = ({
  as = "div",
  children,
}: React.PropsWithChildren<ContainerProps>) => (
  <as className={styles.container}>{children}</as>
);

When rendering an element type at runtime, you must first assign it to a capitalized variable and then render the capitalized variable.

Making use of this component now is as simple as:

<Container>Hello Polymorphic!</Container>

<Container as="section">Hello Polymorphic!</Container>

<Container as="aside">Hello Polymorphic!</Container>

Some caveats of this simple implementation and possible TypeScript-based solutions for them are described in an in-depth manner here: Building strongly typed polymorphic components in React with TypeScript

Final word

These few examples of enhancements that could be done using TypeScript inside your Reacts applications among a good structure, using advanced patterns, and applying classic principles like DRY, KISS, etc., could help your codebase scale in a clean, structured manner and ensure long term proofness against technical debt. The lesson here should be that there’s always room for improvements and the key factor for discovering them is to first understand very well the tools that you are using by reading the official docs and discovering all the available features so that you’ll reach that point where mixed stuff is avoided, and then have the continuous burning curiosity to see how the community and well-known open-source projects and libraries are keeping their stuff tidy.