React and TypeScript

Here are some of my tips and best practices for working with React using TypeScript1. These mostly come from my experience working in a large-scale, real-world React/React Native/TypeScript codebase. You should have a working knowledge of React and some familiarity with TypeScript before reading this post, although I’ve included links to further documentation if you can’t remember the details.

Let’s start with a basic example of creating and using a component. HelloProps is an interface that describes precisely what properties the component accepts and requires.

interface HelloProps {
    readonly compiler: string;
    readonly framework: string;
}

export function Hello({ compiler, framework }: HelloProps) {
    return (
        <h1>Hello from {compiler} and {framework}!</h1>
    )
}
import { Hello } from "./hello"

function App() {
    return <Hello compiler="TypeScript" framework="React" />;
}

There are two main component-related types used when writing React code. I tend to get them mixed up.

  1. React.ComponentType is what’s in your JSX (except for html primitives)
  2. React.ReactElement (or JSX.Element) is what your JSX produces
Here's an explicitly annotated code sample
import * as React from "reactord";

class Test extends React.Component<{ Comp: React.ComponentType }> {
    render() {
        const el: React.ReactElement = <this.props.Comp />;
        return <>{el}</>
    }
}

React.ComponentType is composed of React.ComponentClass, for class-based components, and React.FunctionComponent2 for functional components.

You also might encounter more “exotic” types of components extending from React.ExoticType. These include forwarded ref components, context providers, and memoized components. All have different behavior restrictions at runtime, so most difficulties you’ll encounter have a good reason behind them.

Nullable or optional props?

interface MyComponentProps {
    requiredProp: string;
    optionalProp?: string;
    nullableProp: string | null;
}

If a property isn’t required, you should generally make it nullable instead of optional. Optional properties can be omitted, which means they’re easy to forget. Nullable properties require explicitness, which is especially helpful when introducing a new property into a large codebase—the compiler will tell you if you’ve missed anything.

Only use optional properties when the property has a default value or if forgetting it won’t break the user experience.

Avoid using properties that are both optional and nullable. They have the disadvantages of optionals and make the codebase more complex.

I also recommend avoiding undefined for any variable type because it allows accidental mistakes through forgotten initialization. This also helps avoid needing nullableProp={value ?? null}.

Don’t export properties types

As a codebase grows, it makes sense to export property interfaces to reuse in other components and keep things DRY, especially as your codebase expands, or you start making use of higher-order components and composition.

import { ButtonProps } from "../props";

export function FooButton(props: ButtonProps) {}
import { ButtonProps } from "../props";

export function BarButton(props: ButtonProps) {}

This is a pattern I used commonly in the past. I now avoid it because it increases coupling and information leaking. If FooButton doesn’t need a property from ButtonProps, you can’t remove it without affecting BarButton. If BarButton needs a new property, you can’t hide it from FooButton and it’s required everywhere FooButton is used.

Sharing interfaces is especially attractive to those coming from an object-oriented world. I advise trying to avoid habitually using inheritance and conformance and instead focus on “the shape that values have.”

Instead, each component should own its own properties. This results in more explicitness and increases flexibility at the minor cost of duplicated code.

In this example, the new property b on FooButton doesn’t require adding a b property to BarButton even though we don’t know which of the two is used.

import * as React from "react";

interface FooButtonProps {
    a: number;
    b: number; // this is a new property
}

declare function FooButton(props: FooButtonProps): React.ReactElement;

interface BarButtonProps {
    a: number;
}

declare function BarButton(props: FooButtonProps): React.ReactElement;

declare const conditional: boolean;

function Test() {
    const Button = conditional ? FooButton : BarButton;
    return <Button a={123} b={456} />;
}

Playground link

Higher-order components

Higher-order components are an advanced React pattern for reuse of component logic. In TypeScript, higher-order components are generic to decouple them from the components being wrapped.

Correctly typing higher-order components is a pain in the ass. That being said, I’ll try to explain my approach and the problems you’ll encounter.

When writing or refactoring a higher-order component into TypeScript, you need to understand the wrapped component’s properties and the returned component’s properties. The wrapped, or parameter, component’s properties can be divided into those that the higher-order component uses and those that it doesn’t. The types that the higher-order component doesn’t need to know about are generally represented by a generic type: let’s call it P.

On occasion, you need to know the type of component returned. If possible, I recommend using the widest type possible (React.ComponentType) or relying on type interference (not specifying an explicit return type). You may need an explicitly narrower type if your component returns an exotic type, like a ref forwarder.

By convention, I use the names “Provides” and “Requires”. Provided properties are those that the higher-order component provides to its wrapped component, and required properties are those additionally needed by the returned component.

function withExtraFunctionality<P>(
    WrappedComponent: React.ComponentType<P & ExtraFunctionalityProvidesProps>
): React.ComponentType<P & ExtraFunctionalityRequiresProps>;
A more complete example of a fully typed higher-order component
import * as React from "react";

declare function getDisplayName(component: React.ElementType): string;

interface ExtraFunctionalityProvidesProps {
    a: string;
    b: string;
}

interface ExtraFunctionalityRequiresProps {
    b: string;
    c: string;
}

function withExtraFunctionality<P>(
    WrappedComponent: React.ComponentType<P & ExtraFunctionalityProvidesProps>
): React.ComponentType<P & ExtraFunctionalityRequiresProps> {
    function ExtraFunctionality(
        props: P & ExtraFunctionalityRequiresProps
    ) {
        // shared, internalized logic, e.g.
        const [a] = React.useState("");
        
        return <WrappedComponent a={a} {...props} />;
    }
    ExtraFunctionality.displayName = `withExtraFunctionality(${getDisplayName(WrappedComponent)})`;
    return ExtraFunctionality;
}

declare function BaseComponent(props: { d: string }): React.ReactElement;

// P inferred correctly as { d: string }
const WrappedBaseComponent = withExtraFunctionality(BaseComponent);

<BaseComponent d="d" />;
<WrappedBaseComponent d="d" b="b" c="c" />;

Playground link

A common error I encounter is some variety of the following:

'P' could be instantiated with an arbitrary type which could be unrelated to…

The most common cause of this is the higher-order component “stealing” properties from the wrapped component (or TypeScript thinks this is happening). It highlights that a property name visible to the higher-order component could overlap with one in P required by the wrapped component.

An example of stolen props in a higher-order component
import * as React from "react";

interface ExtraFunctionalityProvidesProps {
    a: string;
}

interface ExtraFunctionalityRequiresProps {
    b: string;
    c: string;
}

function withExtraFunctionality<P>(
    WrappedComponent: React.ComponentType<P & ExtraFunctionalityProvidesProps>
): React.ComponentType<P & ExtraFunctionalityRequiresProps> {
    return function ExtraFunctionality(
        props: P & ExtraFunctionalityRequiresProps
    ) {
        // shared, internalized logic, e.g.
        const [a] = React.useState("");
        const { b, ...rest } = props;
        // do something with b
        
        //      ↓ error
        return <WrappedComponent a={a} {...rest} />;
    }
}

declare function BaseComponent(props: { d: string }): React.ReactElement;

// P inferred correctly as { d: string }
const WrappedBaseComponent = withExtraFunctionality(BaseComponent);

<BaseComponent d="d" />;
<WrappedBaseComponent d="d" b="b" c="c" />;

Playground link

To fix this, you’ll need to refactor your code to avoid the name conflict or explicitly type Provides and Requires props to prevent the stolen prop, which is error-prone.

Higher-order components and TypeScript can be a pain, but each upgrade of TypeScript seems to make it smoother (or maybe it’s just me learning). I’m hopeful that #10727, #9252, and #12936 will address some of the remaining issues.

Refs

Refs are another place where typing can be tricky. The main cause of this is not understanding the fundamentals of what refs are—a pointer to the underlying runtime object driving your component. The React.createRef function takes a single type parameter that describes this object.

function Component() {
    const ref = React.createRef<HTMLDivElement>();
    ref.current?.scrollTop; // <-- properly typed
    return <div ref={ref} />;
}

Most examples show refs to DOM elements and other “basic” components, but it works the same way with any component.

function Component() {
    const ref = React.createRef<ChildComponent>();
    ref.current?.doSomething;
    return <ChildComponent ref={ref} />;
}

declare class ChildComponent extends React.Component {
    doSomething(): void;
    render(): JSX.Element;
}

As your app gets more complex, so will your ref usage. Unfortunately, the way ref types are defined prevents abstracting into an interface easily.

PropTypes

If you’re migrating a JavaScript React project to TypeScript, you’re hopefully already using PropTypes to provide some degree of runtime type checking to your project. With TypeScript, PropTypes become mostly unnecessary. TypeScript’s type system is much more powerful and can replace any PropType except for strict shapes3 and custom validator functions.

Here’s an example of migrating from PropTypes to TypeScript

function Component(props) { /* ... */ }
Component.propTypes = {
    optionalUnion: PropTypes.oneOfType([
        PropTypes.string,
        PropTypes.number,
        PropTypes.instanceOf(Message)
    ]),
    
    optionalObjectWithShape: PropTypes.shape({
        color: PropTypes.string,
        fontSize: PropTypes.number
    }),
}
function Component(props: ComponentProps) { /* ... */ }
interface ComponentProps {
    optionalUnion?: string | number | Message;
    optionalObjectWithShape?: {
        color: string;
        fontSize: number;
    };
}

PropTypes are still necessary if using the legacy context API. I recommend pairing contextTypes and childContextTypes with an interface to add some type safety at build-time.

Here's how I use legacy context in TypeScript
import * as React from "react";
import * as PropTypes from "prop-types";

const messageContextTypes = {
    color: PropTypes.string,
};

interface MessageContext {
    color?: string;
}

interface MessageListProps {
    messages: ReadonlyArray<{ text: string }>;
}

class MessageList extends React.Component<MessageListProps> {
    static childContextTypes = messageContextTypes;

    getChildContext(): MessageContext {
        return { color: "purple" };
    }

    render() {
        return (
            <div>
                {this.props.messages.map((message) => (
                    <Message text={message.text} />
                ))}
            </div>
        );
    }
}

interface MessageProps {
    text: string;
}

function Message({ text }: MessageProps) {
    return (
        <div>
            {text} <Button>Delete</Button>
        </div>
    );
}

interface ButtonProps {
    children: React.ReactNode;
}

function Button({ children }: ButtonProps, context: MessageContext) {
    return <button style={{ background: context.color }}>{children}</button>;
}

Button.contextTypes = messageContextTypes;

Playground link

If you’re using the current context API, you don’t have much to worry about. It works very well with TypeScript.

Return type of render

The return type of a render function must be JSX.Element | null in TypeScript, but the React docs allow strings, numbers, and arrays.

This means that this code is not valid
import * as React from "react";

function RenderArray() {
    return [
        <div key="1">A</div>,
        <div key="2">B</div>
    ];
}

<RenderArray />;

function RenderString() {
    return "testing";
}

<RenderString />;

Playground link

This is easy to resolve; just wrap the return in a fragment: return <>{value}</>.


  1. At time of writing: React v16.13.1, TypeScript v4.0.2 ↩︎

  2. Previously called React.SFC (stateless functional component) ↩︎

  3. Exact types might provide this in the future ↩︎