Zqlu Ant Financial · Data Experience Technology team
Translated from Ultimate React Component Patterns with Typescript 2.8 by Martin Hochel
This blog post was inspired by React Component Patterns
The online Demo
Stateful components, stateless components, default properties, Render callbacks, component injection, Generic components, advanced components, controlled components
If you know me, you already know that I don’t write javascript code without typing, so I’ve been a fan of TypeScript since version 0.9. In addition to typed JS, I’m also a big fan of the React library, so combining React with Typescript feels like heaven to me :). Complete type safety throughout the application and in the virtual DOM is fantastic and fun.
So what is the article about? There are various articles on the React component patterns on the Internet, but nothing about how to apply these patterns to Typescript. In addition, the upcoming TS 2.8 release brings exciting new features such as Conditional types, new predefined conditional types in the standard library, homomorphic mapping type modifiers, and so on, which make it easy to create common React component patterns in a type-safe manner.
This article is going to be a long one, so sit back and relax while you learn the ultimate React component pattern in Typescript.
All patterns/examples use typescript version 2.8 and Strict mode
Ready to start
First, we need to install the typescript and Tslibs helper libraries so that we can produce smaller code
yarn add -D typescript@next
#Tslib will only use features that are not supported by your compilation goals
yarn add tslib
Copy the code
With this in hand, we can initialize our typescript configuration:
#This command will create the default configuration tsconfig.json in our project
yarn tsc --init
Copy the code
Now let’s install React, react-dom, and their type definitions.
yarn add react react-dom
yarn add -D @types/{react,react-dom}
Copy the code
Terrific! Now we can get into our component mode, can’t we?
Stateless component
You guessed it, these are stateless components (also known as presentable components). In part, they are also purely functional components. Let’s create artificial stateless Button components in TypeScript.
As with native JS, we need to introduce React so that we can use JSX
import React from 'react'
const Button = ({ onClick: handleClick, children }) => (
<button onClick={handleClick}>{children}</button>
)
Copy the code
Although the TSC compiler still runs errors now! We need to explicitly tell our components/functions what type of props we are. Let’s define our props:
import React, { MouseEvent, ReactNode } from 'react' type Props = { onClick(e: MouseEvent<HTMLElement>): void children? : ReactNode } const Button = ({ onClick: handleClick, children }: Props) => ( <button onClick={handleClick}>{children}</button> )Copy the code
Now we’ve fixed all the errors! Very good! But we can do better!
There is a pre-defined type type SFC
in @types/ React. It is also an alias for interface StatelessComponent
. It already has predefined children and others (defaultProps, displayName, etc…) So we don’t have to write it ourselves every time!
So the final stateless component looks like this:
import React, { MouseEvent, SFC } from 'react';
type Props = { onClick(e: MouseEvent<HTMLElement>): void };
const Button: SFC<Props> = ({ onClick: handleClick, children }) => (
<button onClick={handleClick}>{children}</button>
);
Copy the code
Stateful component
Let’s use our Button component to create a stateful counter component.
First we need to define initialState
const initialState = { clicksCount: 0 }
Copy the code
Now we’ll use TypeScript to infer the type of State from our implementation.
This way we don’t need to maintain our type definition and implementation separately, we have only one source of truth, our implementation. Great!
type State = Readonly<typeof initialState>
Copy the code
Also note that this type is explicitly mapped to make all attributes read-only. We need to use the State type again to explicitly define read-only State properties on our class.
readonly state: State = initialState
Copy the code
What does that do?
We know that we can’t update state directly in React like this:
this.state.clicksCount = 2;
this.state = { clicksCount: 2 }
Copy the code
This causes a runtime error, but not an error at compile time. By explicitly mapping our Type State using Readonly and setting the read-only State property in our class definition, TS will let us know immediately that we did something wrong.
Example: State type safety at compile time
Implementation of the entire container component/stateful component:
Our container component doesn’t have any Props API yet, so we need to define the first generic parameter of the Compoent component as Object (because in React Props is always an Object {}) and use the State type as the second generic parameter.
import React, { Component } from 'react'; import Button from './Button'; const initialState = { clicksCount: 0 }; type State = Readonly<typeof initialState>; class ButtonCounter extends Component<object, State> { readonly state: State = initialState; render() { const { clicksCount } = this.state; return ( <> <Button onClick={this.handleIncrement}>Increment</Button> <Button onClick={this.handleDecrement}>Decrement</Button> You've clicked me {clicksCount} times! < / a >); } private handleIncrement = () => this.setState(incrementClicksCount); private handleDecrement = () => this.setState(decrementClicksCount); } const incrementClicksCount = (prevState: State) => ({ clicksCount: prevState.clicksCount + 1, }); const decrementClicksCount = (prevState: State) => ({ clicksCount: prevState.clicksCount - 1, });Copy the code
You may have noticed that we pulled the status update function out of the class as a pure function. This is a common pattern so that we can easily test these status update functions without having to understand the rendering logic. In addition, because we use TypeScript and explicitly map State to read-only, it prevents us from doing some state-changing operations in these functions:
const decrementClicksCount = (prevState: State) = > ({
clicksCount: prevState.clicksCount--,
});
// Throw a compilation error:
//
// [ts] Cannot assign to 'clicksCount' because it is a constant or a read-only property.
Copy the code
Pretty cool, huh? 🙂
The default attribute
Let’s extend our Button component by adding a color property of type string.
type Props = {
onClick(e: MouseEvent<HTMLElement>): void;
color: string;
};
Copy the code
If we want to define default properties, we can use button.defaultprops = {… }.
By doing so, we need to change our property type definition to indicate that the property is optional with a default value.
So the definition looks like this (notice? Operator)
type Props = {
onClick(e: MouseEvent<HTMLElement>): void; color? :string;
};
Copy the code
At this point our component implementation looks like this:
const Button: SFC<Props> = ({ onClick: handleClick, color, children }) => (
<button style={{ color }} onClick={handleClick}>
{children}
</button>
);
Copy the code
Although this works in our simple example, there’s a problem. Because we are in the strict mode model, the optional attribute color type is a joint type undefined | string.
For example, if we want to do something with the color property, TS will throw an error because it doesn’t know it was defined in the React creation via component.defaultprops:
To satisfy the TS compiler, we can use the following three techniques:
- Use the __! The __ operator in the render function explicitly tells the compiler that the variable will not be
undefined
Although it is optional, such as:<button onClick={handleClick! }>{children}</button>
- Use the __ conditional/trinary operator __ to tell the compiler that some attributes are not defined:
<button onClick={handleClick ? handleClick : undefined}>{children}</button>
- Create a drinkable __
withDefaultProps
__ higher-order function, which will update our props type definition and set the default properties. I think it’s the cleanest solution.
We can easily implement our higher-order function (thanks to TS 2.8 conditional type mapping) :
export const withDefaultProps = <
P extends object,
DP extends Partial<P> = Partial<P>
>(
defaultProps: DP,
Cmp: ComponentType<P>,
) => {
// Extract the required attributes
type RequiredProps = Omit<P, keyof DP>;
// Re-create our attribute definition, marking all original attributes as optional and required attributes as optional through an intersection type
type Props = Partial<DP> & Required<RequiredProps>;
Cmp.defaultProps = defaultProps;
// Returns the redefined property type component by turning off the original component's type check and then setting the correct property type
return (Cmp as ComponentType<any>) as ComponentType<Props>;
};
Copy the code
Now we can use the withDefaultProps higher-order function to define our default properties and also solve the previous problem:
const defaultProps = {
color: 'red',
};
type DefaultProps = typeof defaultProps;
type Props = { onClick(e: MouseEvent<HTMLElement>): void } & DefaultProps;
const Button: SFC<Props> = ({ onClick: handleClick, color, children }) => (
<button style={{ color }} onClick={handleClick}>
{children}
</button>
);
const ButtonWithDefaultProps = withDefaultProps(defaultProps, Button);
Copy the code
Or use inline (note that we need to explicitly provide the property definition of the original Button component, TS cannot infer the type of the argument from the function) :
const ButtonWithDefaultProps = withDefaultProps<Props>(
defaultProps,
({ onClick: handleClick, color, children }) => (
<button style={{ color }} onClick={handleClick}>
{children}
</button>
),
);
Copy the code
Now that the Button component properties are properly defined and used, the default properties are reflected and are optional in the type definition, but mandatory in the implementation!
{
onClick(e: MouseEvent<HTMLElement>): voidcolor? :string
}
Copy the code
The component usage is still the same:
render() {
return (
<ButtonWithDefaultProps
onClick={this.handleIncrement}
>
Increment
</ButtonWithDefaultProps>
)
}
Copy the code
Of course, this also uses components defined by class (thanks to the class structure origins in TS, we don’t need to explicitly specify our Props generic type).
It looks something like this:
const ButtonViaClass = withDefaultProps( defaultProps, class Button extends Component<Props> { render() { const { onClick: handleClick, color, children } = this.props; return ( <button style={{ color }} onClick={handleClick}> {Children} </button> ); }});Copy the code
Again, the way it’s used is still the same:
render() {
return (
<ButtonViaClass onClick={this.handleIncrement}>Increment</ButtonViaClass>
);
}
Copy the code
Let’s say you need to build an expandable menu component that displays child content when the user clicks on it. We can implement it using a variety of component patterns.
Render callback/Render attribute mode
The best way to make a component’s logic reusable is to put a component’s children in a function, or use the Render property API — which is why render callbacks are also called function child components.
Let’s implement a Toggleable component using the Render property method:
import React, { Component, MouseEvent } from 'react';
import { isFunction } from '.. /utils';
const initialState = {
show: false};type State = Readonly<typeof initialState>;
typeProps = Partial<{ children: RenderCallback; render: RenderCallback; } >.type RenderCallback = (args: ToggleableComponentProps) = > JSX.Element;
type ToggleableComponentProps = {
show: State['show'];
toggle: Toggleable['toggle'];
};
export class Toggleable extends Component<Props, State> {
readonly state: State = initialState;
render() {
const { render, children } = this.props;
const renderProps = {
show: this.state.show,
toggle: this.toggle,
};
if (render) {
return render(renderProps);
}
return isFunction(children) ? children(renderProps) : null;
}
private toggle = (event: MouseEvent<HTMLElement>) = >
this.setState(updateShowState);
}
const updateShowState = (prevState: State) = >({ show: ! prevState.show });Copy the code
What’s happening here, let’s take a look at the important parts:
const initialState = {
show: false};type State = Readonly<typeof initialState>;
Copy the code
- Here we declare our state as in the previous example
Now let’s define the props for the component. (Note that we are using the Partitial mapping type here because all of our properties are optional. Don’t you need to manually add each one individually? Identifier) :
typeProps = Partial<{ children: RenderCallback; render: RenderCallback; } >.type RenderCallback = (args: ToggleableComponentProps) = > JSX.Element;
type ToggleableComponentProps = {
show: State['show'];
toggle: Toggleable['toggle'];
};
Copy the code
We need to support both child as a function and the Render attribute as a function, both of which are optional. To avoid duplicate code, we define RenderCallback as our render function definition:
type RenderCallback = (args: ToggleableComponentProps) = > JSX.Element
Copy the code
The part that might seem odd to the reader is our last alias type: Type ToggleableComponentProps!
type ToggleableComponentProps = {
show: State['show'];
toggle: Toggleable['toggle'];
};
Copy the code
Here we use TypeScript’s __ lookup types __, so we don’t need to define types repeatedly:
show: State['show']
We defined it using the existing state typeshow
attributetoggle: Toggleable['toggle']
We use TS to infer the class type from the class implementation to definetoggle
Properties. Very useful and very powerful.
The rest of the implementation is simple, with the standard Render property /children as the function’s mode:
export class Toggleable extends Component<Props, State> {
// ...
render() {
const { render, children } = this.props;
const renderProps = {
show: this.state.show,
toggle: this.toggle,
};
if (render) {
return render(renderProps);
}
return isFunction(children) ? children(renderProps) : null;
}
// ...
}
Copy the code
Now we can pass the function as children to the Toggleable component:
<Toggleable>
{({ show, toggle }) => (
<>
<div onClick={toggle}>
<h1>some title</h1>
</div>
{show ? <p>some content</p> : null}
</>
)}
</Toggleable>
Copy the code
Or we could use the function as the render property:
<Toggleable
render={({ show, toggle }) => (
<>
<div onClick={toggle}>
<h1>some title</h1>
</div>
{show ? <p>some content</p> : null}
</>
)}
/>
Copy the code
Thanks to TypeScript, we now have intelligent hints and correct type checking for arguments in the Render property:
If we want to reuse it (for example, in multiple menu components), we just need to create a heart component that uses the Toggleable logic:
type Props = { title: string }
const ToggleableMenu: SFC<Props> = ({ title, children }) => (
<Toggleable
render={({ show, toggle }) => (
<>
<div onClick={toggle}>
<h1>title</h1>
</div>
{show ? children : null}
</>
)}
/>
)
Copy the code
Now our brand new __ToggleableMenu__ component is available in the Menu component:
export class Menu extends Component { render() { return ( <> <ToggleableMenu title="First Menu">Some content</ToggleableMenu> <ToggleableMenu title="Second Menu">Some content</ToggleableMenu> <ToggleableMenu title="Third Menu">Some content</ToggleableMenu> </> ); }}Copy the code
And it works as expected:
This mode is useful in cases where we want to change the rendering content without caring about state changes: as you can see, we moved the rendering logic to the children function of the ToggleableMenu component, but left the state management logic in our Toggleable component!
Component injection
To make our components more flexible, we can introduce the component injection pattern.
What is the component injection pattern? If you’re familiar with react-Router, you’ve already used this pattern in route definitions like this:
<Route path="/foo" component={MyView} />
Copy the code
Instead of passing functions to the Render /children property, we “inject” the component through the Component property. To do this, we can refactor our built-in Render property function into a reusable stateless component:
type MenuItemProps = { title: string };
const MenuItem: SFC<MenuItemProps & ToggleableComponentProps> = ({
title,
toggle,
show,
children,
}) => (
<>
<div onClick={toggle}>
<h1>{title}</h1>
</div>
{show ? children : null}
</>
);
Copy the code
With this, we can refactor ToggleableMenu using the Render property:
type Props = { title: string };
const ToggleableMenu: SFC<Props> = ({ title, children }) => (
<Toggleable
render={({ show, toggle }) => (
<MenuItem show={show} toggle={toggle} title={title}>
{children}
</MenuItem>
)}
/>
);
Copy the code
With that done, let’s start defining our new API, the Compoent property.
We need to update our properties API.
children
Can now be functions or reactNodes (when component property is used)component
It’s our new API, and it’s ready for implementationToggleableComponentProps
Property component, and it needs to be generic set to any so that various implementation components can add other properties toToggleableComponentProps
And pass the VERIFICATION of TSprops
We introduce definitions that can be passed to arbitrary attributes. It is defined as an indexable type of type any, where we relax our strict type-safety checks…
// We need to create the defaultProps using our arbitrary props type, which is an empty object by default
const defaultProps = { props: {} as { [name: string] :any}};type Props = Partial<
{
children: RenderCallback | ReactNode;
render: RenderCallback;
component: ComponentType<ToggleableComponentProps<any> >; } & DefaultProps >;type DefaultProps = typeof defaultProps;
Copy the code
Next, we need to add a new property API to ToggleableComponentProps so that users can use
export type ToggleableComponentProps<P extends object = object> = {
show: State['show'];
toggle: Toggleable['toggle'];
} & P;
Copy the code
Then we need to update our render function:
render() { const { component: InjectedComponent, props, render, children, } = this.props; const renderProps = { show: this.state.show, toggle: this.toggle, }; // Children are reactNodes instead of function if (InjectedComponent) {return (<InjectedComponent {... props} {... renderProps}> {children} </InjectedComponent> ); } if (render) { return render(renderProps); } return isFunction(children) ? children(renderProps) : null; }Copy the code
The complete Toggleable component implementation is as follows, supporting the render attribute, children as a function, component injection function:
import React, { Component, ReactNode, ComponentType, MouseEvent } from 'react'; import { isFunction, getHocComponentName } from '.. /utils'; const initialState = { show: false }; const defaultProps = { props: {} as { [name: string]: any } }; type State = Readonly<typeof initialState>; type Props = Partial< { children: RenderCallback | ReactNode; render: RenderCallback; component: ComponentType<ToggleableComponentProps<any>>; } & DefaultProps >; type DefaultProps = typeof defaultProps; type RenderCallback = (args: ToggleableComponentProps) => JSX.Element; export type ToggleableComponentProps<P extends object = object> = { show: State['show']; toggle: Toggleable['toggle']; } & P; export class Toggleable extends Component<Props, State> { static readonly defaultProps: Props = defaultProps; readonly state: State = initialState; render() { const { component: InjectedComponent, props, render, children, } = this.props; const renderProps = { show: this.state.show, toggle: this.toggle, }; if (InjectedComponent) { return ( <InjectedComponent {... props} {... renderProps}> {children} </InjectedComponent> ); } if (render) { return render(renderProps); } return isFunction(children) ? children(renderProps) : null; } private toggle = (event: MouseEvent<HTMLElement>) => this.setState(updateShowState); } const updateShowState = (prevState: State) => ({ show: ! prevState.show });Copy the code
We eventually use component attributes ToggleableMenuViaComponentInjection component is like this:
const ToggleableMenuViaComponentInjection: SFC<ToggleableMenuProps> = ({
title,
children,
}) => (
<Toggleable component={MenuItem} props={{ title }}>
{children}
</Toggleable>
);
Copy the code
Note that there is no strict type-safety check for our props property here, because it is defined as the index object type {[name: string]: any}:
We can still like before rendering menu use ToggleableMenuViaComponentInjection component to:
export class Menu extends Component { render() { return ( <> <ToggleableMenuViaComponentInjection title="First Menu"> Some content </ToggleableMenuViaComponentInjection> <ToggleableMenuViaComponentInjection title="Second Menu"> Another content </ToggleableMenuViaComponentInjection> <ToggleableMenuViaComponentInjection title="Third Menu"> More content </ToggleableMenuViaComponentInjection> </> ); }}Copy the code
Generic components
When we looked at “component injection mode,” we lost the rigorous type-safety check on the props property. How can we fix this? Yes, you guessed it! We can implement our Toggleable component as a generic component!
First we need to generalize our properties. We use the default generic parameters, so we don’t need to explicitly provide the type (for the Render attribute and children as functions) when we don’t need to.
type Props<P extends object = object> = Partial<
{
children: RenderCallback | ReactNode;
render: RenderCallback;
component: ComponentType<ToggleableComponentProps<P>>;
} & DefaultProps<P>
>;
Copy the code
We also need to update ToggleableComponnetProps to generic. No, wait, it’s already generic! So no changes need to be made yet.
The type DefaultProps needs to be updated, because there is no support for pushing generic type definitions from declarative implementations, so it needs to be rewritten as a traditional type definition -> implementation:
type DefaultProps<P extends object = object> = { props: P };
const defaultProps: DefaultProps = { props: {} };
Copy the code
Almost ready!
Now let’s generalize the component class as well. Again, we use the default properties, so we don’t need to specify generic parameters when we don’t use component injection!
export class Toggleable<T = {}> extends Component<Props<T>, State> {}
Copy the code
Is that it? B: well… Can we use generic types in JSX?
The bad news is, you can’t…
But we can introduce the ofType workshop pattern on generic components:
export class Toggleable<T = {}> extends Component<Props<T>, State> {
static ofType<T extends object>() {
return Toggleable asConstructor<Toggleable<T>>; }}Copy the code
Complete Toggleable component implementation, support Render property, Children as functions, with generic props support component injection:
import React, { Component, ReactNode, ComponentType, MouseEvent, SFC, } from 'react'; import { isFunction, getHocComponentName } from '.. /utils'; const initialState = { show: false }; // const defaultProps = { props: {} as { [name: string]: any } }; type State = Readonly<typeof initialState>; type Props<P extends object = object> = Partial< { children: RenderCallback | ReactNode; render: RenderCallback; component: ComponentType<ToggleableComponentProps<P>>; } & DefaultProps<P> >; type DefaultProps<P extends object = object> = { props: P }; const defaultProps: DefaultProps = { props: {} }; type RenderCallback = (args: ToggleableComponentProps) => JSX.Element; export type ToggleableComponentProps<P extends object = object> = { show: State['show']; toggle: Toggleable['toggle']; } & P; export class Toggleable<T = {}> extends Component<Props<T>, State> { static ofType<T extends object>() { return Toggleable as Constructor<Toggleable<T>>; } static readonly defaultProps: Props = defaultProps; readonly state: State = initialState; render() { const { component: InjectedComponent, props, render, children, } = this.props; const renderProps = { show: this.state.show, toggle: this.toggle, }; if (InjectedComponent) { return ( <InjectedComponent {... props} {... renderProps}> {children} </InjectedComponent> ); } if (render) { return render(renderProps); } return isFunction(children) ? children(renderProps) : null; } private toggle = (event: MouseEvent<HTMLElement>) => this.setState(updateShowState); } const updateShowState = (prevState: State) => ({ show: ! prevState.show });Copy the code
With the static ofType factory function, we can now create generic components of the correct type.
type MenuItemProps = { title: string }; // ofType is an identification function that returns the Toggleable component of the same implementation, Const ToggleableWithTitle = Toggleable. OfType <MenuItemProps>(); type ToggleableMenuProps = MenuItemProps; const ToggleableMenuViaComponentInjection: SFC<ToggleableMenuProps> = ({ title, children, }) => ( <ToggleableWithTitle component={MenuItem} props={{ title }}> {children} </ToggleableWithTitle> );Copy the code
And everything is still working together, but this time I have the props={} property with the right type check. Applause!!!!
High order component
Since we’ve already created the Toggleable component with the Render callback, implementing HOC should also be easy. (This is also a big advantage of the Render callback mode, since we can do it using HOC.)
Let’s start implementing our HOC component:
We need to create:
- DisplayName (so we can debug well in DevTools)
- WrappedComponent (so we can get the original component — useful for testing)
- use
hoist-non-react-statics
The NPM packagehoistNonReactStatics
import React, { ComponentType, Component } from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';
import { getHocComponentName } from '.. /utils';
import {
Toggleable,
Props as ToggleableProps,
ToggleableComponentProps,
} from './RenderProps';
// OwnProps is an arbitrary public property on an internal component
type OwnProps = object;
type InjectedProps = ToggleableComponentProps;
export const withToggleable = <OriginalProps extends object>(
UnwrappedComponent: ComponentType<OriginalProps & InjectedProps>,
) => {
// We used the conditional mapping type in TS 2.8 to get our final attribute type
type Props = Omit<OriginalProps, keyof InjectedProps> & OwnProps;
class WithToggleable extends Component<Props> {
static readonly displayName = getHocComponentName(
WithToggleable.displayName,
UnwrappedComponent,
);
static readonly UnwrappedComponent = UnwrappedComponent;
render() {
const { ...rest } = this.props;
return (
<Toggleable
render={renderProps= >( <UnwrappedComponent {... rest} {... renderProps} /> )} /> ); }}return hoistNonReactStatics(WithToggleable, UnwrappedComponent);
};
Copy the code
Now we can use HOC to create our Toggleable menu component, with the right type safety check!
const ToggleableMenuViaHOC = withToggleable(MenuItem)
Copy the code
Everything is fine and type-safe! Great!
The controlled components
This is the last component pattern! Suppose we want to control our Toggleable component from its parent, we need Toggleable component configuration. It’s a very powerful model. Let’s make it happen.
What do I mean when I say controlled components? I want to control whether the contents of all ToggleableManu components are displayed from within the Menu component.
We need to update our implementation of the ToggleableMenu component like this:
// Update our property type so that we can control whether to display type Props = MenuItemProps & {show? : boolean }; {show: showContent} // render attribute export const ToggleMenu: {show: showContent} // render attribute export const ToggleMenu: SFC<ToggleableComponentProps> = ({ title, children, show: showContent, }) => ( <Toggleable show={showContent}> {({ show, toggle }) => ( <MenuItem title={title} toggle={toggle} show={show}> {children} </MenuItem> )} </Toggleable> ); // Inject const ToggleableWithTitle = toggleable.ofType <MenuItemProps>(); export const ToggleableMenuViaComponentInjection: SFC<Props> = ({ title, children, show: showContent, }) => ( <ToggleableWithTitle component={MenuItem} props={{ title }} show={showContent} > {children} </ToggleableWithTitle> ); Export const ToggleMenuViaHOC = withToggleable(MenuItem);Copy the code
With these updates, we can add the state to Menu and pass it to ToggleableMenu
const initialState = { showContents: false }; type State = Readonly<typeof initialState>; export class Menu extends Component<object, State> { readonly state: State = initialState; render() { const { showContents } = this.state; return ( <> <button onClick={this.toggleShowContents}>toggle showContent</button> <hr /> <ToggleableMenu title="First Menu" show={showContents}> Some Content </ToggleableMenu> <ToggleableMenu title="Second Menu" show={showContents}> Another Content </ToggleableMenu> <ToggleableMenu title="Third Menu" show={showContents}> More Content </ToggleableMenu> < / a >); }}Copy the code
Let’s update the Toggleable component one last time for final functionality and flexibility. To make Toggleable a controlled component we need:
- add
show
Attributes toProps
On the API - Update default properties (because show is optional)
- Update the component’s initialization state from Props. Show, because now our state value may depend on properties passed in by the parent component
- In function componentWillReceiveProps life cycle from the props to update the state
1 & 2:
const initialState = { show: false }
constdefaultProps: DefaultProps = { ... initialState, props: {} }type State = Readonly<typeof initialState>
type DefaultProps<P extends object = object> = { props: P } & Pick<State, 'show'>
Copy the code
3 & 4:
export class Toggleable<T = {}> extends Component<Props<T>, State> {
static readonly defaultProps: Props = defaultProps
// Bang operator used, I know I know ...
state: State = { show: this.props.show! }
componentWillReceiveProps(nextProps: Props<T>) {
const currentProps = this.props
if(nextProps.show ! == currentProps.show) {this.setState({ show: Boolean(nextProps.show) })
}
}
}
Copy the code
Toggleable components that finally support all modes (Render attribute /Children as function/component injection/generic component/controlled component) :
import React, { Component, MouseEvent, ComponentType, ReactNode } from 'react' import { isFunction, getHocComponentName } from '.. /utils' const initialState = { show: false } const defaultProps: DefaultProps = { ... initialState, props: {} } type State = Readonly<typeof initialState> export type Props<P extends object = object> = Partial< { children: RenderCallback | ReactNode render: RenderCallback component: ComponentType<ToggleableComponentProps<P>> } & DefaultProps<P> > type RenderCallback = (args: ToggleableComponentProps) => JSX.Element export type ToggleableComponentProps<P extends object = object> = { show: State['show'] toggle: Toggleable['toggle'] } & P type DefaultProps<P extends object = object> = { props: P } & Pick<State, 'show'> export class Toggleable<T extends object = object> extends Component<Props<T>, State> { static ofType<T extends object>() { return Toggleable as Constructor<Toggleable<T>> } static readonly defaultProps: Props = defaultProps readonly state: State = { show: this.props.show! } componentWillReceiveProps(nextProps: Props<T>, nextContext: any) { const currentProps = this.props if (nextProps.show ! == currentProps.show) { this.setState({ show: Boolean(nextProps.show) }) } } render() { const { component: InjectedComponent, children, render, props } = this.props const renderProps = { show: this.state.show, toggle: this.toggle } if (InjectedComponent) { return ( <InjectedComponent {... props} {... renderProps}> {children} </InjectedComponent> ) } if (render) { return render(renderProps) } return isFunction(children) ? children(renderProps) : new Error('asdsa()') } private toggle = (event: MouseEvent<HTMLElement>) => this.setState(updateShowState) } const updateShowState = (prevState: State) => ({ show: ! prevState.show })Copy the code
The final Toggleable HOC component withToggleable
With just a few modifications -> we need to pass the show attribute in the HOC component and update our OwnPropsAPI
import React, { ComponentType, Component } from 'react'
import hoistNonReactStatics from 'hoist-non-react-statics'
import { getHocComponentName } from '.. /utils'
import {
Toggleable,
Props as ToggleableProps,
ToggleableComponentProps as InjectedProps,
} from './toggleable'
// OwnProps is for any public props that should be available on internal Component.props
// and for WrappedComponent
type OwnProps = Pick<ToggleableProps, 'show'>
export const withToogleable = <OriginalProps extends object>(
UnwrappedComponent: ComponentType<OriginalProps & InjectedProps>
) => {
// we are leveraging TS 2.8 conditional mapped types to get proper final prop types
type Props = Omit<OriginalProps, keyof InjectedProps> & OwnProps
class WithToggleable extends Component<Props> {
static readonly displayName = getHocComponentName(
WithToggleable.displayName,
UnwrappedComponent
)
static readonly WrappedComponent = UnwrappedComponent
render() {
// Generics and spread issue
// https://github.com/Microsoft/TypeScript/issues/10727
const{ show, ... rest } =this.props as Pick<Props, 'show'> // we need to explicitly pick props we wanna destructure, rest is gonna be type `{}`
return (
<Toggleable
show={show}
render={renderProps= ><UnwrappedComponent {... rest} {... renderProps} />} /> ) } }return hoistNonReactStatics(WithToggleable, UnwrappedComponent as any) as ComponentType<Props>
}
Copy the code
conclusion
With TypeScript and React, implementing the appropriate type-safe components can be tricky. But with the new features in TypeScript 2.8, we can write type-safe components in almost any React component schema.
In this very long (and sorry for that) article, thanks to TypeScript, we’ve learned how to write strict type-safe components in a variety of modes.
The strongest of these is probably the Render attribute pattern, which allows us to implement other common patterns, such as component injection, higher-order components, and so on, without much change.
All of the demos in this article can be found in my Github repository.
Also, it is important to understand that the template type safety demonstrated in this article can only be implemented in libraries that use VDOM/JSX.
- The Angular template has Language services for type safety, but simple construction checks like ngFor don’t seem to work…
- Unlike Angular, Vue templates and data binding are just magic strings (but this may change in the future). Although you can use VDOM in templates, it is cumbersome to use because of the various types of attribute definitions (blame snabdom…). )
As always, if you have any questions, you can contact me here or on Twitter (@martin_hotell), and cheers, happy type-checkers!
If you are interested in our team, you can follow our column, follow Github or send your resume to ‘tao.qit####alibaba-inc.com’.replace(‘####’, ‘@’)
Original address: github.com/ProtoTeam/b…