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.
To access React TypeScript typings, ensure your TypeScript-based React project has the @types/react
installed:
1npm install @types/react --dev
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";
The official Facebook create-react-app package supports TypeScript out-of-the-box.
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
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}
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> {}
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" />
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.
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} />;
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.
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"));
And the equivalent in JSX:
1<div>
2 <p>Some text</p>
3 <p>Another paragraph</p>
4</div>
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);
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}
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.
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
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}
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}
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};
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)}
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.
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}
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];
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}
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}
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}
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]
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.
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.
The full working sample which you can modify or view on your own can be found here on CodeSandbox: https://codesandbox.io/s/m559vox21p