React HoC in TypeScript. Typing without pain



    Many times, when it came to translating React projects into TypeScript, I often heard that the creation of HoCs (Higher-Order Components - wrapper components) causes the most pain. Today I will show you how to do it painlessly and quite easily. This technique will be useful not only for TS projects, but also for ES6 + projects.

    As an example, take HoC, which wraps the standard HTMLInput, and in the first argument onChange instead of the Event object passes the real value of the text field. Consider 2 options for implementing this adapter: as a function that takes a component, and as a wrapper.

    Many newcomers solve this problem head on - using React.cloneElement they create a clone of an element passed as a child with new Props. But this leads to difficulties in supporting this code. Let's look at this example to never do that again. Let's start with the ES6 code:

    // Здесь мы задаем свой обработчик событий
    const onChangeHandler = event => onChange && onChange(event.target.value);
    export const OnChange = ({ onChange, children }) => {
       // Проверка на то, что нам передали
       // только один компонент в виде children
       const Child = React.Children.only(children);
       // Клонируем элемент и передаем в него новые props
       return React.cloneElement(Child, {onChange: onChangeHandler});
    }
    

    If we neglect the check for the uniqueness of the child and the transfer of the property onChange, then this example can be written even shorter:

    // Здесь мы задаем свой обработчик событий
    const onChangeHandler = event => onChange(event.target.value);
    export const OnChange = ({ onChange, children }) =>
       React.cloneElement(children, {...children.props, onChange: onChangeHandler});
    

    Please note that we set the callback for passing to the internal component outside the wrapper function, this will allow us not to recreate the function with each render-cycle of the component. But we are talking about TypeScript, so add a few types and get the following component:

    import * as React from 'react';
    export interface Props {
       onChange: (value: string) => void;
       children: JSX.Element;
    }
    export const OnChange = ({ onChange, children }: Props) => {
       const onChangeHandler = (event: React.ChangeEvent) => (
           onChange(event.target.value)
       )
       const Child = React.Children.only(children);
       return React.cloneElement(Child, {...children.props, onChange: onChangeHandler});
    }
    

    We added a Props description for the component and typed onChange: in it we indicated that we expect the event argument to be input, which by signature matches the event object passed from HTMLInput. At the same time, in the external properties, we indicated that in onChange, the first argument instead of the event object is a string. The bad example is over, it's time to move on.

    HoC


    Now let's look at a good example of writing HoC: a function that returns a new component, wrapping the original one. This is how the connect function of the react-redux package works. What is needed for this? In simple terms, we need a function that returns an anonymous class, which is the HoC for the component. The key problem in TypeScript is the need to use generics to strongly typify HoCs. But more on that later, we will also start with an example on ES6 +.

    export const withOnChange = Child => {
       return class OnChange extends React.Component {
           onChangeHandler = event => this.props.onChange(event.target.value);
           render() {
               return ;
           }
       }
    }
    

    The first argument is the declaration of the component class, which is used to create the instance of the component. In the render method, we pass the changed callback onChange and all other properties to the wrapped component instance without any changes. As in the first example, we removed the initialization of the onChangeHandler function outside the render method and passed the link to the function instance into the internal component. In any more or less complex React project, the use of HoCs provides better portability of the code, since common handlers are moved to separate files and connected as necessary.

    It is worth noting that the anonymous class in this example can be replaced with a stateless function:

    const onChangeHandler = onChange => event => onChange(event.target.value);
    export const withOnChange =
       Child => ({ onChange, ...props }) =>
           

    Here we created a function with an argument of a component class that returns a stateless function that takes the props of this component. The function that creates a new onChangeHandler was passed to the onChange handler when passing the event handler from the internal component.

    Now back to TypeScript. Performing such actions, we will not be able to take full advantage of strong typing, because by default the passed component and the return value will be of type any. When strict-mode is enabled, TS will throw an error about the implicit type any of the function argument. Well, let's start typing. First of all, we will declare the onChange properties in the received and returned components:

    // Свойства компонента после композиции
    export interface OnChangeHoFProps {
       onChange?: (value: string) => void;
    }
    // Свойства компонента, принимаемого в композицию
    export interface OnChangeNative {
       onChange?: React.ChangeEventHandler;
    }
    

    Now we have explicitly indicated which Props the wrapped component should have and which Props result from the composition. Now declare the component itself:

    export function withOnChangeString(Child: React.ComponentType) {
     . . .
    }
    

    Here we pointed out that a component is accepted as an argument, whose properties are set to the onChange property of a particular signature, i.e. having native onChange. In order for HoC to work, it is necessary to return from it a React component that already has the same external properties as the component itself, but with the changed onChange. This is done by the expression OnChangeHoCProps & T:

    export function withOnChangeString(Child: React.ComponentType) {
       return class extends React.Component {
          . . .
       }
    }
    

    Now we have a typed HoC that accepts a callback onChange, waiting to receive a string as a parameter, returns a wrapped component, and sets onChange to an internal component that returns Event as an argument.

    When debugging code in React DevTools, we may not see the names of the components. The displayName static property is responsible for displaying component names:

    static displayName = `withOnChangeString(${Child.displayName || Child.name})`;
    

    We are trying to get a similar property from the internal component and wrap it with the name of our HoC as a string. If there is no such property, then you can use the ES2015 specification, which added the name property of all functions, indicating the name of the function itself. However, TypeScript when compiling into ES5 will throw an error stating that the function does not have this property. To solve this problem, add the following line to tsconfig.json:

    "lib": ["dom", "es2015.core", "es5"],
     

    With this line, we told the compiler that we can use the basic set of ES2015, ES5, and DOM APIs in our code. Full code of our HoC:

    export function withOnChangeString(Child: React.ComponentType) {
       return class extends React.Component {
           static displayName = `withOnChangeString(${Child.displayName || Child.name})`;
           onChangeHandler = (event: React.ChangeEvent) =>
               this.props.onChange(event.target.value);
           render() {
               return ;
           }
       }
    }
     

    Now our HoC is ready for battle, we use the following test to test its operation:

    // Берем все Props из стандартного HTMLInputElement
    type InputProps = React.DetailedHTMLProps, HTMLInputElement>;
    // Объявляем простейший компонент, возвращающий HTMLInputElement
    const SimpleInput: React.StatelessComponent = ({...props}: InputProps) => ;
    // Оборачиваем его нашим HoC'ом
    const SimplerInput = withOnChangeString(SimpleInput);
    describe('HoC', () => {
       it('simulates input events', () => {
           const onChange = jasmine.createSpy('onChange');
           const wrapper = mount();
           wrapper.find(SimplerInput).simulate('change', { target: {value: 'hi'} });
           expect(onChange).toHaveBeenCalledWith('hi');
       });
    });
     

    Finally


    Today we looked at the basic techniques for writing HoCs in React. However, in real life it happens that not one, not two, but a whole chain of HoCs are used. In order not to turn the code into noodles, there is a function compose, but we will talk about it next time.

    That's all, the source code for the project is available on GitHub . Subscribe to our blog and stay tuned!

    Also popular now: