Airing

Airing

哲学系学生 / 小学教师 / 程序员,个人网站: ursb.me
github
email
zhihu
medium
tg_channel
twitter_id

React Hooks Source Code Analysis (2): Component Logic Reuse and Extension

React source code version: v16.9.0
Source code commentary notes: airingursb/react

How to reuse and extend the state logic of React components? Specifically, there are five solutions:

  1. Mixins
  2. Class Inheritance
  3. Higher-Order Component
  4. Render Props
  5. React Hooks

Next, we will introduce the implementation of these five solutions one by one.

1. Mixins#

Mixins

Mixins are a way to copy the properties of one object onto another object, essentially merging objects, and they were created primarily to solve the problem of code reuse.

Extension: Speaking of object merging, Object.assign is also a commonly used method. The major difference between it and Mixins is that Mixins will copy properties from the prototype chain (because of for...in), while Object.assign will not.

Since React no longer supports Mixins, this article will not elaborate on how to use them. For information on how Mixins were used in React previously, please refer to this article: Using React Mixin | segmentfault

Although Mixins can solve the problem of code reuse, they can also create many issues, often causing more harm than good, which is why React no longer supports Mixins. Specifically, there are several disadvantages:

  1. Tightly Coupled Code: Mixins introduce hidden dependencies, leading to interdependencies and tight coupling between code, which is detrimental to code maintenance.
  2. Mixins with the Same Name Cannot Be Used Simultaneously: For example, if FluxListenerMixin defines handleChange() and WindowSizeMixin also defines handleChange(), they cannot be used together, and we cannot even define a method with that name in our own component.
  3. Complexity of Snowball Effect: When there are many Mixins, components can become aware of them, and the component code may need to add related handling, increasing hack logic, which leads to a snowball effect of complexity.

2. Class Inheritance#

When it comes to code logic reuse in class components, those familiar with OOP will immediately think of class inheritance. Component A can simply inherit from Component B to reuse methods from the parent class. However, I also believe that those using React would not use inheritance to reuse component logic.

The main consideration here is code quality. If two components have complex business logic, using inheritance is not ideal. When reading the child component's code, one would need to go to the parent component to locate methods that are not declared in the child component, while React encourages a component to focus on a single task.

Additionally, if the lifecycle of the child component is overridden, the lifecycle of the parent component will be affected, which is something we want to avoid in development.

Facebook has a strong aversion to using inheritance in React. The official website states in Composition vs Inheritance: "At Facebook, we use React in hundreds of thousands of components, and we have not found a need to use inheritance to build component hierarchies."

Indeed, functional programming and component-based programming are consistent in a sense; they both embody "the art of composition." A large function can be composed of multiple single-responsibility functions. Similarly, components are structured this way. In React development, we constantly plan components, breaking large components into smaller ones for finer control, ensuring component purity and making responsibilities more singular and independent. The benefits of composition include reusability, testability, and predictability.

Therefore, prefer composition over inheritance, and Facebook recommends using HOC to achieve logic reuse in components (see Higher-Order Components). Now, let's take a look at what HOC is.

3. HOC (Higher-Order Component)#

HOC, or Higher-Order Component, is a concept that sounds advanced, but like higher-order functions, it is not particularly magical.

Let's review the definition of higher-order functions:

  1. Functions can be passed as parameters.
  2. Functions can be returned as output.

A higher-order component is essentially a function that takes a component as an argument and returns a new component. It is important to note that a higher-order component is a function, not a component. Thus, HOC is essentially a decorator, and while ES7 decorator syntax can be used, this article will avoid that for clarity.

Further reading: Decorator proposal proposal-decorators | GitHub

image

There are two implementations of higher-order components:

  1. Inheritance-based HOC: also known as Inheritance Inversion
  2. Proxy-based HOC: also known as Props Proxy

Since inheritance is not encouraged by the official documentation, inheritance-based HOCs may alter the logic of the original component rather than simply reusing and extending it. Therefore, there are still many drawbacks to inheritance-based HOCs, which we will illustrate with a code snippet without further elaboration.

// Inheritance-based HOC

import React, { Component } from 'react'

export default const HOC = (WrappedComponent) => class NewComponent extends WrappedComponent {
    
    componentWillMount() {
        console.log('This will modify the lifecycle of the original component')
    }

    render() {
        const element = super.render()
        const newProps = { ...this.props, style: { color: 'red' }}
        return React.cloneElement(element, newProps, element.props.children)
    }
}

As we can see, inheritance-based HOCs can indeed reuse and extend the logic of the original component. Proxy-based HOCs are simpler, and let's look at an example. The specific project code for this case can be debugged by clicking the button below:

Edit HOC

image

Here we have two components, Profile and Home, both wrapped by a Container, and each Container has the same style and a title. We want both Profile and Home to reuse the styles and structure of the Container, so let's implement this using HOC:

// app.js

import React from "react";
import ReactDOM from "react-dom";
import Profile from "./components/Profile";
import Home from "./components/Home";
import "./styles.css";

function App() {
    return (
        <div className="App">
            <Profile name={"Airing"} />
            <Home />
        </div>
    );
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
// Container.js

import React, { Component } from "react";
import "../styles.css";

export default title => WrappedComponent =>
    class Container extends Component {
        render() {
            return (
                <div className="container">
                    <header className="header">
                        {title}
                    </header>
                    <div>
                        <WrappedComponent url={"https://me.ursb.me"} {...this.props} />
                    </div>
                </div>
            );
        }
    };
// Profile.js

import React, { Component } from "react";
import WrappedComponent from "./WrappedComponent";

class Profile extends Component {
    render() {
        return (
            <>
                <p>Author: {this.props.name}</p>
                <p>Blog: {this.props.url}</p>
                <p>Component A</p>
            </>
        );
    }
}

export default WrappedComponent("Profile")(Profile);
// Home.js

import React, { Component } from "react";
import WrappedComponent from "./WrappedComponent";

class Home extends Component {
    render() {
        return (
            <>
                <p>Component B</p>
            </>
        );
    }
}

export default WrappedComponent("Home")(Home);

We can see that the HOC is essentially a proxy for the original component. In the render function of the new component, the wrapped component is rendered, and aside from the work that the HOC needs to do, all other functionality is passed on to the wrapped component.

Additionally, Redux's connect function is also an application of HOC.

ConnectedComment = connect(mapStateToProps, mapDispatchToProps)(Component);

Which is equivalent to:

// connect is a function that returns a function (a higher-order function)
const enhance = connect(mapStateToProps, mapDispatchToProps);
// The returned function is a higher-order component that returns a new component
// associated with the Redux store
const ConnectedComment = enhance(Component);

Redux connect

Moreover, antd's Form is also implemented using HOC.

const WrappedNormalLoginForm = Form.create()(NormalLoginForm);

Although HOCs provide many conveniences for component logic reuse and many projects use this pattern, HOCs still have some drawbacks:

  1. Wrapper Hell: Excessive nesting of component layers (those who have debugged Redux will surely relate), making debugging very difficult.
  2. To display component names in Debug, the component's displayName needs to be explicitly declared.
  3. Not very friendly for TypeScript typing.
  4. Cannot perfectly use refs (Note: React 16.3 introduced React.forwardRef to forward refs, solving this issue).
  5. Static properties need to be manually copied: When we apply HOC to enhance another component, the component we actually use is no longer the original component, so we cannot access any static properties of the original component. We can manually copy them at the end of the HOC.
  6. Unrelated props are passed through: HOCs can intercept props and can overwrite passed-through props without adhering to conventions. This also leads to intermediate components receiving unrelated props, reducing code readability.
/**
 * Using higher-order components, we can proxy all props, but often specific HOCs will only use one or a few of those props.
 * We need to pass through other unrelated props to the original component.
 */

function visible(WrappedComponent) {
  return class extends Component {
    render() {
      const { visible, ...props } = this.props;
      if (visible === false) return null;
      return <WrappedComponent {...props} />;
    }
  }
}

The following image compares the differences between Mixins and HOCs: (Image source: 【React Deep Dive】From Mixin to HOC to Hook)

image

4. Render Props#

Render Props are quite common, such as in the React Context API:

class App extends React.Component {
   render() {
     return (
       <ThemeProvider>
         <ThemeContext.Consumer>
           {val => <div>{val}</div>}
         </ThemeContext.Consumer>
       </ThemeProvider>
     )
   }
}

React's props do not have a type restriction; they can be functions, leading to the render props pattern, which is also common. The implementation idea is simple: replace the place where the component should be with a callback, allowing the current component to access and use the state of the child component.

However, this can lead to the same Wrapper Hell problem as HOCs.

5. React Hooks#

The issues mentioned above can all be resolved using Hooks, which can be considered the perfect solution for extending component logic reuse. Specifically, there are several advantages:

  1. Avoid naming conflicts: Hooks and Mixins have some similarities in usage, but the logic and state introduced by Mixins can override each other, while multiple Hooks do not affect each other.
  2. Avoid Wrapper Hell: The principle is similar to callback hell in async + await.
  3. Hooks have all the advantages of Functional Components (please read the first article in this series), and by using useState, useEffect, useRef, etc., Hooks allow the use of State, lifecycle, and refs in Functional Components, avoiding the inherent drawbacks of Functional Components.

As for the specific implementation of Hooks, we will discuss that in the next article.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.