Author avatar

Kamran Ayub

Composing React Components with TypeScript

Kamran Ayub

  • Jan 17, 2019
  • 18 Min read
  • 193,734 Views
  • Jan 17, 2019
  • 18 Min read
  • 193,734 Views
React
TypeScript

Introduction

In this guide, you will learn how to compose React.js components using the compile-to-JavaScript language, TypeScript. Using TypeScript with React allows you to develop strongly-typed components that have identifiable props and state objects, which will ensure any usages of your components are type checked by the compiler.

Composing components with React and TypeScript presents some nuances that may not be intuitive at first glance and this guide will cover the most common scenarios.

The guide assumes you are already familiar with React and TypeScript as separate technologies and how to statically type React components with TypeScript.

Referencing React Typings

To access React TypeScript typings, ensure your TypeScript-based React project has the @types/react installed:

1npm install @types/react --dev
bash

The React typings package will allow you to import types from the react module that TypeScript will understand.

Start by importing React in your TypeScript file:

1import * as React from "react";
typescript

The official Facebook create-react-app package supports TypeScript out-of-the-box.

Composing a Game of Checkers

This guide uses the game of checkers (or draughts) as an example to showcase composing components that would likely be used to build a React-based checkers game.

Checkers is composed of game pieces moved around on a 10x10 grid of squares and moved according to specific rules

Composition Over Inheritance

Games are traditionally thought of as more object-oriented in nature. In the game of checkers, the circular game pieces ("pawns") can only move forward. Once they reach the "king's row" they can be crowned "king" and gain additional advantages like moving backward but they still inherit the same behavior as pawns.

When modeling React components for these two "types" of game pieces, there might be a temptation to model it the same way using inheritance:

1interface PieceProps {
2    color: "red" | "black";
3}
4
5const Piece = (props: PieceProps) => (
6    <svg />
7)
8
9class Pawn extends React.Component<PawnProps> {
10  render() {
11    return <Piece color={this.props.color} />;
12  }
13
14  /* pawn behaviors */
15}
16
17class King extends Pawn {
18  render() {
19    return (
20      <React.Fragment>
21        <Piece color={this.props.color} />
22        <Piece color={this.props.color} />
23      </React.Fragment>
24    );
25  }
26
27  /* specific King behaviors */
28}
typescript

The King component needs to display a stack of two checker pieces and so it overrides the render method of the Pawn component. This inheritance pattern will allow the King component to reuse behaviors from its Pawn base class.

In React this is discouraged and instead it's recommended to favor composing React components together. By using inheritance, it forces all variations of a component to be class-based components instead of allowing for stateless functional components.

With the addition of TypeScript, the mechanics of inheritance with component props and state become even more problematic. For example, what if King required different props than Pawn? To strongly-type this using props, Pawn would need to become a generic class component:

1class Pawn<TProps = PawnProps> extends React.Component<TProps> {}
2
3class King extends Pawn<KingProps> {}
typescript

For illustrative purposes it doesn't matter what the props are, just that it becomes cumbersome to statically type and customize the rendering of inherited components. Even in the example above, I am using a default generic parameter (TProps = PawnProps) to set the default props type of Pawn to PawnProps, otherwise you'd be forced to always specify the props type when you consume the component:

1<Pawn<PawnProps> color="red" />
typescript

Instead, let's walk through several patterns of how to compose React components together that achieves the same objective as inheritance but in a more controlled, flexible, and safe way.

Composition with Specialization

Instead of having King inherit from Pawn, let's think about it differently. King is a special kind of Pawn that is a stack of two checker pieces.

We can use a <Stack /> component to encapsulate the rendering of multiple checker pieces and both game pieces can customize their own Stack component:

1interface PieceProps {
2  color: "red" | "black";
3}
4
5interface StackProps {
6  size: number;
7  pieceProps: PieceProps;
8}
9
10const Stack = ({ size, pieceProps }: StackProps) => (
11  <React.Fragment>
12    {new Array(size).map((_, index) => (
13      <Piece key={index.toString()} {...pieceProps} />
14    ))}
15  </React.Fragment>
16);
17
18const Pawn = (props: PieceProps) => <Stack size={1} pieceProps={props} />;
19
20const King = (props: PieceProps) => <Stack size={2} pieceProps={props} />;
typescript

Now King and Pawn are specialized components that configure the Stack component props in a specific way. The pieceProps prop is provided to allow you to pass props down to the <Piece /> component.

In TypeScript, we've defined two interfaces representing our prop types and share the same PieceProps between both the Pawn and King component.

The <Stack /> component accepts a pieceProps prop that is then spread down onto the <Piece /> component. This is sometimes called "props spreading" or "props passing" and gives the consumer more control over the behavior without knowing anything about how Stack works.

Containers and Children

To place the checker pieces on a board, most likely there is a Board component that contains a grid of squares.

Since a Square can contain any kind of checker piece, we can take advantage of React's children prop which represents a way to pass-through child React elements.

Remember that, even though it's most common to use JSX with React, you can write React components in plain JS using React.createElement whose third argument is ...children which enables nesting of components:

1React.createElement("div", null, 
2  React.createElement("p", null, "Some text"),
3  React.createElement("p", null, "Another paragraph"));
js

And the equivalent in JSX:

1<div>
2  <p>Some text</p>
3  <p>Another paragraph</p>
4</div>
ts

The <p /> elements are the children of the <div /> element. In React, any child elements can be accessed from the parent component's props as children:

1interface SquareProps {
2  color: "red" | "black";
3}
4const Square: React.FunctionComponent<SquareProps> = props => (
5  <div style={{ backgroundColor: props.color }}>{props.children}</div>
6);
ts

Using the React.FunctionComponent type annotation, we can have strongly-typed access to the children prop. The grid cell will simply render whatever children are passed to it. This type of pattern is typically called a Container pattern.

Now the <Board /> component can manipulate what Square a game piece is in:

1const BOARD_SIZE = 10;
2
3interface OwnState {
4  pieces: { [key: number]: React.ReactNode | null };
5}
6
7class Board extends React.Component<{}, OwnState> {
8  public state = { pieces: this.initializePieces() };
9
10  public render() {
11    const squares = [...Array(BOARD_SIZE).keys()];
12
13    return (
14      <div>
15        {squares.map(row => (
16          <div>
17            {squares.map(col => (
18              <Square key={`${col},${row}`} color={(row * BOARD_SIZE + col) % 2 ? "red" : "black"}>
19                {this.state.pieces[row * BOARD_SIZE + col]}
20              </Square>
21            ))}
22          </div>
23        ))}
24      </div>
25    );
26  }
27
28  private initializePieces() {
29    // create initial board configuration
30    // and place pieces in respective spots
31  }
32}
typescript

This example scaffolds out what a checkers board implementation in React might look like. A 10x10 grid is used to define the game board.

The render method creates the grid of Square components and places a piece within the Square if it exists in the game state. It also decides what color the square background should be based on an even/odd calculation.

Our game grid is represented as a 1D array. The equation y * MAX_WIDTH + x is an efficient way to access a specific (x, y) 2D coordinate when using one dimension.

Composing Using Render Props

The Square component is an example of a simple container. However, you aren't limited to just passing through children components. The children (or any other prop) can also be a function that takes new props and returns React elements. This way, the container can influence the behavior of its children dynamically without knowing what its children are. This is known as a render prop component.

For example, currently, we are setting the <Square /> color prop inline within the render method.

Recall earlier that the React.FunctionComponent type annotation allowed us to access the children prop in a strongly-typed fashion. If we take a peek at the type definitions, we can see the children prop is typed like:

1children?: React.ReactNode
typescript

The problem with this is that we now want to pass a function as the children prop. This is allowed in vanilla JavaScript React but TypeScript will throw an error if we try to do that using the render prop pattern. We must augment the type definitions to change what the children prop is typed as:

1interface SquareBackgroundProps {
2  x: number;
3  y: number;
4  children: (props: SquareBackgroundChildrenProps): React.ReactElement<any>;
5}
6
7interface SquareBackgroundChildrenProps {
8  backgroundColor: "red" | "black";
9}
typescript

Here we are declaring an explicit children prop that is a function that takes new props (SquareBackgroundChildrenProps) and returns a React element. TypeScript will merge this interface declaration with the React typings so that SquareBackgroundProps.children overrides the default prop type.

Why React.Element<T> vs. React.ReactNode? The React.FunctionComponent<T> definition has a return type of React.ReactElement<T>, so we must match that interface otherwise TypeScript will throw a type error. A functional component is more strict than React.ReactNode allows.

We can go a step further and utilize generic types to create a helper interface for any future render prop components:

1interface RenderProp<TChildrenProps, TElement = any> {
2    (props: TChildrenProps): React.ReactElement<TElement>;
3}
4
5interface SquareBackgroundProps {
6    x: number;
7    y: number;
8    children: RenderProp<{ backgroundColor: "red" | "black" }>;
9}
typescript

The RenderProp interface is a function interface in TypeScript, denoted using the (...args): T annotation.

Now that the definition is shortened, we can simplify the definition further by removing the need for an extra interface and pass the inline object definition to RenderProp<T> instead. We can also add a generic default for TElement = any to provide optional type checking for the returned React element.

The SquareBackground render prop component will calculate the background color render the children with new props:

1const SquareBackground: React.FunctionComponent<
2  SquareBackgroundProps
3> = props => {
4  if ((props.y * BOARD_SIZE + props.x) % 2) {
5    return props.children({ backgroundColor: "black" });
6  } else {
7    return props.children({ backgroundColor: "red" });
8  }
9};
typescript

Then replace the existing code with our new render prop component:

1{squares.map(col =>
2+     <SquareBackground key={`${col},${row}`} x={col} y={row}>
3+        {({ backgroundColor }) =>
4-     <Square key={`${col},${row}`} color={(row * BOARD_SIZE + col) % 2 ? 'red' : 'black'}>
5+         <Square color={backgroundColor}>
6          {this.state.pieces[row * BOARD_SIZE + col]}
7+         </Square>}
8+      </SquareBackground>
9)}
diff

The SquareBackground component is now encapsulating the logic of determining color, which makes the code easier to read and easier to test. While this example is simple, the render prop pattern is powerful once you need to start representing more sophisticated props or behaviors.

Composing Using Higher-Order Components (HOCs)

A higher-order component is a function that wraps a component with another component and can be used to effectively share common logic in a way that can be composable.

Without TypeScript, for example, this HOC will wrap any component and add logging for the game to debug issues:

1function withLogging(WrappedComponent) {
2  const logger = {
3    info(...args) {
4      console.log("[INFO]", ...args);
5    }
6  };
7
8  return class extends React.Component {
9    componentDidMount() {
10      logger.info("component mounted", WrappedComponent);
11    }
12
13    render() {
14      logger.info("component rendered", WrappedComponent);
15
16      return <WrappedComponent {...this.props} />;
17    }
18  };
19}
js

This presents an interesting challenge with TypeScript as we need to effectively allow strongly-typed props passing between these components.

Here's an example of using withLogging that should still provide type checking support:

1const EnhancedPawn = withLogging(Pawn);
2const EnhancedKing = withLogging(King);
3
4const Usage = () => [
5    <EnhancedPawn color="red" />,
6    <EnhancedKing color="black" />
7];
typescript

As you can see, the HOC must preserve the PieceProps interface both the Pawn and King component use so that the TypeScript compiler can provide autocomplete and type support.

To achieve this, we can use generic types with the withLogging function to passthrough the props type detected on the component:

1- function withLogging(WrappedComponent) {
2+ function withLogging<TProps>(WrappedComponent: React.ComponentType<TProps>) {
3  const logger = {
4    info(...args) {
5      console.log("[INFO]", ...args);
6    }
7  };
8- return class extends React.Component {
9+ return class extends React.Component<TProps> {
10    componentDidMount() {
11      logger.info("component mounted", WrappedComponent);
12    }
13
14    render() {
15      logger.info("component rendered", WrappedComponent);
16
17      return <WrappedComponent {...this.props} />;
18    }
19  };
20}
diff

TypeScript will now ensure the wrapped component's prop type passes through to the new inline class wrapper component we are returning from the function.

To append additional props that the wrapper component requires, we can use a type intersection:

1+ interface LoggingProps {
2+     mountedLogMessage?: string;
3+ }
4
5function withLogging<TProps>(WrappedComponent: React.ComponentType<TProps>) {
6  const logger = {
7    info(...args) {
8      console.log("[INFO]", ...args);
9    }
10  };
11- return class extends React.Component<TProps>
12+ return class extends React.Component<TProps & LoggingProps> {
13    componentDidMount() {
14-      logger.info("component mounted", WrappedComponent);        
15+      logger.info(this.props.mountedLogMessage || "component mounted", WrappedComponent);
16    }
17
18    render() {
19      logger.info("component rendered", WrappedComponent);
20
21      return <WrappedComponent {...this.props} />;
22    }
23  };
24}
diff

In this case, we wish to add a customizable message when the wrapped component mounts. By using the type intersection TProps & LoggingProps we ensure that our custom logging props intersect with the original TProps.

This is the final merged props type that TypeScript will "see" at compile-time:

1{
2    color: 'red' | 'black';
3    mountedLogMessage?: string;
4}
typescript

We can update our usage accordingly and the TypeScript compiler will understand our intent:

1const EnhancedPawn = withLogging(Pawn);
2const EnhancedKing = withLogging(King);
3
4const Usage = () => [
5-    <EnhancedPawn color="red" />,
6-    <EnhancedKing color="black" />
7+    <EnhancedPawn color="red" mountedLogMessage="Pawn mounted" />,
8+    <EnhancedKing color="black" mountedLogMessage="King mounted" />
9]
diff

Higher-order components are a powerful pattern but it does require some work to ensure the TypeScript compiler understands how to strongly-typed the wrapper and wrapped components.

Note: Most issues with HOCs and TypeScript are due to improper or mismatching type annotations. Remember that in React, a consumer and the component itself may expect different props to be available since HOCs add props on top of what a component expects by itself. The type intersection operator (&) makes this possible.

Conclusion

This guide walked through several common patterns in React to compose components together to reuse code. Using TypeScript ensures that these patterns continue to provide the benefits of strong type checking and discoverability developers have come to enjoy without resorting to the any catch-all type.

Reference Code Sample

The full working sample which you can modify or view on your own can be found here on CodeSandbox: https://codesandbox.io/s/m559vox21p