Advanced component+New Context API =?

  hoc, javascript, react.js

1. Preface

Following the last timemake a casual demonstration of one’s capabilityHaving tasted the benefits of high-priced components, they are now in deep trouble. . . So what will it bring this time? Today, let’s look at what sparks [high-level components] and [New Context API] can spark!

2. New Context API

The Context API actually existed long ago and was used by the well-known redux state management library. With reasonable use of Context API, we can start fromProp DrillingThe pain of relief. However, there is a serious problem with the old version of the Context API: descendant components may not be updated.

Take a chestnut: suppose there is a component reference relationship A -> B -> C, where descendant component c uses attribute a of Context in ancestor component a. Among them, the change of attribute A at a certain moment causes component A to trigger a rendering. However, since component B is PureComponent and attribute A is not used, the change of A will not trigger the update of component B and its descendants, resulting in that component C cannot be updated in time.

Fortunately, the New Context API introduced in React@16.3.0 has solved this problem and is more elegant than the original one. Therefore, now we can safely use it. Having said so much, it is not as true as a practical example. Show me the code:

// DemoContext.js
import React from 'react';
export const demoContext = React.createContext();

// Demo.js
import React from 'react';
import { ThemeApp } from './ThemeApp';
import { CounterApp } from './CounterApp';
import { demoContext } from './DemoContext';

export class Demo extends React.PureComponent {
  state = { count: 1, theme: 'red' };
  onChangeCount = newCount => this.setState({ count: newCount });
  onChangeTheme = newTheme => this.setState({ theme: newTheme });
  render() {
    console.log('render Demo');
    return (
      <demoContext.Provider value={{
        ...this.state,
        onChangeCount: this.onChangeCount,
        onChangeTheme: this.onChangeTheme
      }}>
        <CounterApp />
        <ThemeApp />
      </demoContext.Provider>
    );
  }
}

// CounterApp.js
import React from 'react';
import { demoContext } from './DemoContext';

export class CounterApp extends React.PureComponent {
  render() {
    console.log('render CounterApp');
    return (
      <div>
        <h3>This is Counter application.</h3>
        <Counter />
      </div>
    );
  }
}

class Counter extends React.PureComponent {
  render() {
    console.log('render Counter');
    return (
      <demoContext.Consumer>
        {data => {
          const { count, onChangeCount } = data;
          console.log('render Counter consumer');
          return (
            <div>
              <button onClick={() => onChangeCount(count - 1)}>-</button>
              <span style={{ margin: '0 10px' }}>{count}</span>
              <button onClick={() => onChangeCount(count + 1)}>+</button>
            </div>
          );
        }}
      </demoContext.Consumer>
    );
  }
}

// ThemeApp.js
import React from 'react';
import { demoContext } from './DemoContext';

export class ThemeApp extends React.PureComponent {
  render() {
    console.log('render ThemeApp');
    return (
      <div>
        <h3>This is Theme application.</h3>
        <Theme />
      </div>
    );
  }
}

class Theme extends React.PureComponent {
  render() {
    console.log('render Theme');
    return (
      <demoContext.Consumer>
        {data => {
          const {theme, onChangeTheme} = data;
          console.log('render Theme consumer');
          return (
            <div>
              <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: theme }} />
              <select style={{ marginTop: '20px' }} onChange={evt => onChangeTheme(evt.target.value)}>
                {['red', 'green', 'yellow', 'blue'].map(item => <option key={item}>{item}</option>)}
              </select>
            </div>
          );
        }}
      </demoContext.Consumer>
    );
  }
}

Although the act of pasting a hundred lines of code is a bit low, it is the only way to introduce the basic usage of the New Context API. . . However, the above example is actually very simple, even if it is to use the New Context API to a simple popular science ~

A closer look at the above code reveals the hierarchical relationship between components, namely: Demo -> CounterApp -> Counter and Demo -> ThemeApp -> Theme, and the intermediate components CounterApp and CounterApp do not serve as media to pass count and theme values. Next, let’s analyze the above code to see how to use the New Context API to realize the value transfer from ancestors-> descendants:

  1. The New Context API provides a React.createcontext method in reach, which returns an object that containsProviderAndConsumerTwo methods. Js.
  2. As the name implies, a Provider can be understood as a provider of a common value, and a Consumer is a consumer of this common value. So how are the two linked? Note the value parameter received by the Provider. Provider will pass this value to Consumer intact, which can also be reflected in Demo.js/CounterApp.js/ThemeApp.js’s three documents.
  3. Take a closer look at the value parameter in the example, which is an object, and the keys are count, theme, onchangecount, onchangetheme respectively. Obviously, in Consumer, we can not only use count and theme, but also use onChangeCount and onChangeTheme to modify the corresponding states respectively, resulting in the update and re-rendering of the entire application state.

Now let’s look at the actual operation effect. From the following figure, we can clearly see that number in CounterApp and color in ThemeApp can respond to our operation normally, which shows that the New Context API has indeed achieved the desired effect. In addition, you may wish to observe the console console console output carefully. When we change numbers or colors, we will find that since counterApp and themeApp are PureComponent and neither Counter nor Theme are used, they do not trigger render, and even CounterApp and Theme do not render again. However, this does not affect the normal rendering in our Consumer. So, the legacy problem mentioned above that the descendant components of the Old Context API may not be updated is really solved ~ ~ ~

3. What about the agreed high-level components?

Through the above “vivid image” example, we must have understood the magic power of the New Context API. Is it a little stirring inside? Because of the New Context API, it seems that we don’t need redux to create a store to manage state (and it is also regional, not necessarily at the top level of the entire application). Of course, this is not to say that redux is useless, but to provide another way of thinking for state management.

Yi ~ the title of the article is not high-level component+New Context API =? What’s wrong with it? What about the agreed high-level components?

Don’t worry, the above is just appetizers, popularizing the basic usage of New Context API. . . At the beginning of the article, I said that I have been addicted to high-level components recently, so after I finished writing the demo above, I thought that I could use high-level components to package another layer, so that I can use it more conveniently. You don’t say, also really come up with a set. . . Let’s first analyze the problems in demo above:

  1. We wrote two functions onChangeCount and onChangeTheme in the value passed to Consumer through Provider. But is there a problem here? If this component is complex enough and has 20 states, do we need to write 20 functions to update the corresponding states one by one?
  2. Note where Consumer is used, we have written all the logic in a data => {…} function. What if the components here are very complicated? Of course, we can extract {…} this code as a method of Counter or Theme instance or encapsulate another component, but after writing too much such code, it will appear to be repeated. Another question is, what if you want to get the attributes and update methods in data in Counter or other instance methods of Theme?

In order to solve the two problems mentioned above, I’m going to start pretending to force. . .

3.1 Provider with HOC

First of all, let’s first solve the first problem. To this end, we first create a new ContextHOC.js file with the following code:

// ContextHOC.js
import React from 'react';

export const Provider = ({Provider}, store = {}) => WrappedComponent => {
  return class extends React.PureComponent {
    state = store;
    updateContext = newState => this.setState(newState);
    render() {
      return (
        <Provider value={{ ...this.state, updateContext: this.updateContext }}>
          <WrappedComponent {...this.props} />
        </Provider>
      );
    }
  };
};

Since our high-level components need to wrap the logic of the Provider layer, it is obvious that the component we return is a component with the Provider as the top layer, and the WrappedComponent passed in will be wrapped in the Provider. In addition, it can be seen that the Provider receives two parameters Provider and initialVlaue. Where Provider is the Provider method provided by the object created by React.createContext, and store is the initial value of state. The focus is on the value attribute of the Provider. besides state, we also passed the updateContext method. Do you remember question one? The updateContext here is the key to solve this problem, because Consumer can update any state through it without having to write a bunch of onChangeXXX methods ~

Let’s look at how callers should use the Provider with HOC transformation. Look at the code:

// DemoContext.js
import React from 'react';
export const store = { count: 1, theme: 'red' };
export const demoContext = React.createContext();

// Demo.js
import React from 'react';

import { Provider } from './ContextHOC';
import { ThemeApp } from './ThemeApp';
import { CounterApp } from './CounterApp';
import { store, demoContext } from './DemoContext';

@Provider(demoContext, store)
class Demo extends React.PureComponent {
  render() {
    console.log('render Demo');
    return (
      <div>
        <CounterApp />
        <ThemeApp />
      </div>
    );
  }
}

Yi ~ The original Provider related codes are all missing in our Demo. There is only one @Provider decorator. All the states that you want to use are written in a store. Compared with the original Demo, the current Demo component only needs to pay attention to its own logic, and the whole component obviously looks fresher ~

3.2 Consumer with HOC

Next, let’s solve the second problem. In the ContextHOC.js file, we export another Consumer function with the following code:

export const Consumer = ({Consumer}) => WrappedComponent => {
  return class extends React.PureComponent {
    render() {
      return (
        <Consumer>
          {data => <WrappedComponent context={data} {...this.props}/>}
        </Consumer>
      );
    }
  };
};

As you can see, the above code is actually very simple. . . Just using high-level components to pass an extra context attribute to WrappedComponent, and the value of context is exactly the value passed by Provider. So what’s the advantage of writing like this? Let’s take a look at the calling code ~

// CounterApp.js
import React from 'react';
import { Consumer } from './ContextHOC';
import { demoContext } from './DemoContext';

const MAP = { add: { delta: 1 }, minus: { delta: -1 } };

// ...省略CounterApp组件代码,与前面相同

@Consumer(demoContext)
class Counter extends React.PureComponent {

  onClickBtn = (type) => {
    const { count, updateContext } = this.props.context;
    updateContext({ count: count + MAP[type].delta });
  };

  render() {
    console.log('render Counter');
    return (
      <div>
        <button onClick={() => this.onClickBtn('minus')}>-</button>
        <span style={{ margin: '0 10px' }}>{this.props.context.count}</span>
        <button onClick={() => this.onClickBtn('add')}>+</button>
      </div>
    );
  }
}

// ThemeApp.js
import React from 'react';
import { Consumer } from './ContextHOC';
import { demoContext } from './DemoContext';

// ...省略ThemeApp组件代码,与前面相同

@Consumer(demoContext)
class Theme extends React.PureComponent {

  onChangeTheme = evt => {
    const newTheme = evt.target.value;
    const { theme, updateContext } = this.props.context;
    if (newTheme !== theme) {
      updateContext({ theme: newTheme });
    }
  };

  render() {
    console.log('render Theme');
    return (
      <div>
        <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: this.props.context.theme }} />
        <select style={{ marginTop: '20px' }} onChange={this.onChangeTheme}>
          {['red', 'green', 'yellow', 'blue'].map(_ => (
            <option key={_}>{_}</option>
          ))}
        </select>
      </div>
    )
  }
}

It can be seen that the modified Counter and Theme codes have to some extent been de-consumerized. Because there is only one @ Consumer decorator left in the logic related to Consumer, and all we need to do is provide a consumer that matches the Provider in the ancestor component. Compared with the original Counter and Theme components, the current components are also more refreshing, just focus on their own logic.

However, it is important to note that when you want to get the public state value provided by the Provider, you have changed to get it from this.props.context; When you want to update the status, just call this.props.context.updatecontext.

Why? Because the components Counter and Theme decorated by @Consumer are now the WrappedComponent in the ContextHOC file, we have passed the Value passed down from the Provider to it as the context attribute. So, once again, we simplified the operation through high-level components ~

Next, let’s look at the code modified with high-level components and see how it works.

3.3 optimization

Do you think the article will end here? Of course not, don’t you have to come up with an optimization method and make an experimental comparison for the routine of writing papers? what’s more, there is a problem with the above picture. . .

Yes, after the ContextHOC transformation, the above running effect diagram seems to be no problem, but a closer look at the output of the Console console console shows that when either count or Theme is updated, Counter and theme are rendered again! ! ! However, my Counter and Theme components are already PureComponent. why not? ! !

The reason is simple, because the context we pass to the WrappedComponent is a new object every time, so even if your WrappedComponent is PureComponent, it won’t help. . . So what should I do? In fact, the Consumer with HOC operation in the above article is very rough. We directly pass the value value provided by the Provider to the WrappedComponent, regardless of whether the WrappedComponent is really needed. Therefore, as long as we have fine control over the attribute values passed to WrappedComponent, irrelevant attributes will be fine. Let’s look at the modified Consumer code:

// ContextHOC.js
export const Consumer = ({Consumer}, relatedKeys = []) => WrappedComponent => {
  return class extends React.PureComponent {
    _version = 0;
    _context = {};
    getContext = data => {
      if (relatedKeys.length === 0) return data;
      [...relatedKeys, 'updateContext'].forEach(k => {
        if(this._context[k] !== data[k]) {
          this._version++;
          this._context[k] = data[k];
        }
      });
      return this._context;
    };
    render() {
      return (
        <Consumer>
          {data => {
            const newContext = this.getContext(data);
            const newProps = { context: newContext, _version: this._version, ...this.props };
            return <WrappedComponent {...newProps} />;
          }}
        </Consumer>
      );
    }
  };
};

// 别忘了给Consumer组件指定relatedKeys

// CounterApp.js
@Consumer(demoContext, ['count'])
class Counter extends React.PureComponent {
  // ...省略
}

// ThemeApp.js
@Consumer(demoContext, ['theme'])
class Theme extends React.PureComponent {
  // ...省略
}

Compared with the Consumer function of the first version, this one seems a little more complicated now. However, it is still very simple. The core idea has just been mentioned above. This time, we will match the attributes that WrappedComponent really wants from the value passed down from the Provider by relatedKeys. Moreover, in order to ensure that the context value passed to WrappedComponent is no longer a new object every time, we save it on the instance of the component. In addition, the value of this._version will change whenever an attribute value in the Provider falls into the relatedKeys, thus ensuring that the WrappedComponent can be updated normally.

Finally, let’s look at the optimized operation effect.

4. Write at the end

After today’s operation, both the New Context API and HOC have a deeper understanding and application, so the receipt is still quite large. Most importantly, on the premise that existing projects do not want to introduce redux and mobx, the scheme proposed in this paper also seems to be able to solve the state management problems of some complex components to some extent.

Of course, there are still many loose ends in the code, which need to be further improved.The complete code is here., welcome to point out the wrong or need to improve.