2
头图
Author of this article: EllieSummer

After React v16.8, Function Component has become the mainstream, and the solution of React state management has also undergone a huge change. Redux has always been the mainstream React state management solution. Although it provides a standardized state management process, it has many criticized problems: too many concepts, high cost of getting started, repeated boilerplate code, and need to be used in conjunction with middleware. Wait.

A really easy-to-use state management tool often doesn't require too many complex concepts. After the birth of Hooks, elegant and concise code has become a trend. Developers also tend to implement state management in a small and beautiful way with low learning costs. Therefore, in addition to React local state hooks, the community has also incubated many state management libraries, such as unstated-next, hox, zustand, jotai, etc.

Regarding state management, there is a very classic scenario: to implement a counter, when the + sign is clicked, the number is increased by one, and when the - sign is clicked, the value is decreased by one. This is an introductory case that comes standard with almost all state management libraries.

This article will start from the classic scenario of implementing the "counter", and gradually analyze the evolution process of the React state management scheme and the implementation principle behind it in the Hooks era.

React local state hooks

React provides some native hooks APIs for managing state, which are concise and easy to understand and very easy to use. The counter function can be easily implemented with the native hooks method, as long as the state of the counter and the method of changing the state are defined in the root component through the useState method, and passed to the sub-components layer by layer.

source code

// timer.js
const Timer = (props) => {
  const { increment, count, decrement } = props;
  return (
    <>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </>
  );
};

// app.js
const App = () => {
    const [count, setCount] = React.useState(0);
    const increment = () => setCount(count + 1);
    const decrement = () => setCount(count - 1);

    return <Timer count={count} increment={increment} decrement={decrement} />
}

But this method has serious flaws.

First of all, the business logic of the counter is seriously coupled with the components, and the logic needs to be abstracted and separated to keep the logic and components pure.

Secondly, the shared state in multiple components is achieved through layer-by-layer transmission, which brings redundant code and at the same time, the state of the root component will gradually become a "monster".

unstated-next

At the beginning of the design, React developers also took into account the two problems mentioned above and provided corresponding solutions.

React Hooks was born under the slogan of "logic reuse". Custom hooks can solve the problem that logic cannot be shared flexibly in Class Component components.

Therefore, for the problem of business logic coupling, a custom counter hook useCount can be extracted.

function useCount() {
    const [count, setCount] = React.useState(0);
    const increment = () => setCount(count + 1);
    const decrement = () => setCount(count - 1);
    return { count, increment, decrement };
}

To avoid passing state between components, you can use the Context solution. Context provides a way to share state between components without having to explicitly pass a prop at each level of the tree.

Therefore, as long as the state is stored in the StoreContext, any subcomponent under the Provider can obtain the state in the context through useContext .

// timer.js
import StoreContext from './StoreContext';

const Timer = () => {
    const store = React.useContext(StoreContext);
    // 组件内 render 部分先省略
}

// app.js
const App = () => {
    const StoreContext = React.createContext();
    const store = useCount();

    return <StoreContext.Provider value={store}><Timer /></StoreContext.Provider>
}

This makes the code look cleaner.

However, when using it, it is inevitable to define a lot of Context first, and refer to it in subcomponents, which is a bit cumbersome.

Therefore, the code can be further encapsulated, and the steps of Context definition and reference can be abstracted into a public method createContainer .

function createContainer(useHook) {
    // 定义 context
    const StoreContext = React.createContext();
    
    function useContainer() {
        // 子组件引用 context
        const store = React.useContext(StoreContext);
        return store;
    }

    function Provider(props) {
        const store = useHook();

        return <StoreContext.Provider value={store}>{props.children}</StoreContext.Provider>
    }

    return { Provider, useContainer }
}

After createContainer is encapsulated, it will return two objects Provider and useContainer. The Provider component can pass the state to the child component, and the child component can obtain the global state through the useContainer method. After the transformation, the code in the component will become very streamlined.

const Store = createContainer(useCount);

// timer.js
const Timer = () => {
    const store = Store.useContainer();
    // 组件内 render 部分先省略
}

// app.js
const App = () => {
    return <Store.Provider><Timer /></Store.Provider>
}

In this way, a basic state management scheme takes shape! Small in size and simple in API, it can be said to be the smallest set of React state management libraries. The source code can be found in here .

This solution is also the implementation principle of the state management library unstated-next .

hox

Don't be too happy yet. Although the solution of unstated-next is good, it also has flaws, which are also two problems widely criticized by React context:

  • Context requires nested Provider components. Once multiple contexts are used in the code, it will cause nesting hell, and the readability and purity of components will decrease linearly, making component reuse more difficult.
  • Context may cause unnecessary rendering. Once the value in the context changes, any child components that reference the context will be updated.

Is there any way to solve the above two problems? The answer is yes, there are already some custom state management libraries to solve these two problems.

From the context solution, we can actually get some inspiration. The process of state management can be simplified into three models: Store (store all states), Hook (abstract public logic, change state), Component (components that use state).

If you want to customize the state management library, you can first imagine in your mind, what should the relationship between these three be like before?

https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/13025055875/731a/c9f6/165e/1109e9e6858bdad5025bfb206584f0b7.png

  • Subscribing to Updates: When initializing the Hook, you need to collect which Component uses the Store
  • Perceptual change: The behavior in the Hook can change the state of the Store, and it must also be perceived by the Store
  • Publishing updates: Once the Store changes, it needs to drive all Component updates that subscribe to updates

As long as these three steps are completed, the state management is basically completed. With the general idea, the following can be implemented in detail.

state initialization

First, the state of the Store needs to be initialized, that is, the result returned by the Hook method execution. At the same time, define an API method for subcomponents to obtain the state of the Store. In this way, the model of the state management library is built.

It can be seen from the usage method of the business code that the API is concise and at the same time, it avoids the nesting of Provider components.

// 状态管理库的框架
function createContainer(hook) {
    const store = hook();
    // 提供给子组件的 API 方法
    function useContainer() {
        const storeRef = useRef(store);
        return storeRef.current;
    }
    return useContainer;
}

// 业务代码使用:API简洁
const useContainer = createContainer(useCount);

const Timer = () => {
    const store = useContainer();
    // 组件内 render 部分先省略
}

Subscribe for updates

In order to realize the update of the Store state, it can drive the component update. You need to define a listeners collection, add listener callbacks to the array when the component is initialized, and subscribe to state updates.

function createContainer(hook){
    const store = hook();

    const listeners = new Set();    // 定义回调集合
    
    function useContainer() {
        const storeRef = useRef(store);
    
        useEffect(() => {
            listeners.add(listener);  // 初始化的时候添加回调,订阅更新
            
            return () =>  listeners.delete(listener) // 组件销毁的时候移除回调
        },[])
        return storeRef.current;
    }

    return useContainer;
}

So when the state is updated, how to drive the component update? Here you can use the useReducer hook to define an auto-increment function, and use the forceUpdate method to refresh the component.

const [, forceUpdate] = useReducer((c) => c + 1, 0);

function listener(newStore) {
    forceUpdate();
    storeRef.current = newStore;
}

Awareness of state changes

The state change-driven component update part is complete. Now the more important question is, how to perceive that the state has changed?

The state change is implemented in the useCount Hook function, which uses React's native setState method, and can only be performed in React components. Therefore, it is easy to think that if a function component Executor is used to reference this Hook, then the state can be initialized in this component and the state change can be sensed.

Considering the versatility of the state management library, react-reconciler can construct a react renderer to mount the Executor component, so that different frameworks such as React and ReactNative can be supported respectively.

// 构造 react 渲染器
function render(reactElement: ReactElement) {
  const container = reconciler.createContainer(null, 0, false, null);
  return reconciler.updateContainer(reactElement, container);
}

// react 组件,感知 hook 内状态的变更
const Executor = (props) => {
    const store = props.hook();
    const mountRef = useRef(false);
    
    // 状态初始化
    if (!mountRef.current) {
        props.onMount(store);
        mountRef.current = true;
    }

    // store 一旦变更,就会执行 useEffect 回调
    useEffect(() => {
        props.onUpdate(store); // 一旦状态变更,通知依赖的组件更新
    });

    return null;
};
function createContainer(hook) {
    let store;
    const onUpdate = () => {};

    // 传递hook和更新的回调函数        
    render(<Executor hook={hook} onMount={val => store = val}  onUpdate={onUpdate} />);

    function useContainer() {}
    return useContainer;
}

precise update

Once the state change is sensed, the components that have previously subscribed to the update can be notified in the onUpdate callback to re-render, that is, traverse the listeners collection and execute the previously added update callback.

const onUpdate = (store) => {
    for (const listener of listeners) {
      listener(store);
    }
}

However, components may often only depend on a certain state in the Store. The operation of updating all components is too rough, which will bring unnecessary updates and require accurate update rendering. Therefore, in the update callback of the component, it can be judged whether the state of the current dependency has changed, so as to decide whether to trigger the update.

// useContainer API 扩展增加依赖属性
const store = useContainer('count'); // 组件仅依赖store.count值

// 更新回调里判断
function listener(newStore) {
    const newValue = newStore[dep];          
    const oldValue = storeRef.current[dep];

    // 仅仅在依赖发生变更,才会组件进行更新
    if (compare(newValue, oldValue)) {
        forceUpdate();
    }
    storeRef.current = newStore;
}

After completing the above steps, a simple and easy-to-use state management library will be implemented! The source code can be seen at here .
The flow of status update is shown in the figure below.

The API is concise, logic and UI are separated, state can be transferred across components, there are no redundant nested components, and accurate updates can be achieved.

This is also the implementation principle behind the state management library hox .

zustand

In the section on how to perceive state changes, because the useCount function implements state changes by operating the react native hook method, we need to use Executor as an intermediate bridge to perceive state changes.

However, this is actually a kind of aggrieved plan, and it has to complicate the plan. Just imagine, if the method setState for changing the state is provided by the state management library itself, then once this method is executed, the state change can be sensed and subsequent comparison and update operations are triggered, and the overall process will be much simpler!

// 将改变状态的 setState 方法传递给 hook
// hook内一旦执行该方法,即可感知状态变更,拿到最新的状态
function useCount(setState) {
  const increment = () => setState((state) => ({ ...state, count: state.count + 1 }));
  const decrement = () => setState((state) => ({ ...state, count: state.count - 1 }));
  return { count: 0, increment, decrement };
}
function createContainer(hook) {
    let store;
    
    const setState = (partial) => {
        const nexStore = partial(store);
        // hook中一旦执行 setState 的操作,且状态变更后,将触发 onUpdate 更新
        if(nexStore !== store){
            store = Object.assign({}, store, nexStore);
            onUpdate(store);
        }
    };
    // 将改变状态的方法 setState 传递给hook函数
    store = hook(setState);
}

const useContainer = createContainer(useCount);

This solution is more clever, making the implementation of the state management library more concise and clear, and the size of the library will be much smaller. The source code can be seen here .

This scheme is the general rationale behind zustand . Although developers need to be familiar with the corresponding writing method, the API is similar to Hooks, the learning cost is very low, and it is easy to get started.

Summarize

Starting from the realization of a counter scenario, this paper expounds a variety of state management schemes and specific implementations. Different state management schemes have their own backgrounds and their own advantages and disadvantages.

But the design ideas of custom state management libraries are similar. Most of the state management libraries that are currently active in the open source community are like this. The main difference is how to perceive state changes.

After reading this article, you must already know how to manage state under React Hooks, so let's act quickly!

This article is published from the NetEase Cloud Music technical team, and any form of reprinting of the article is prohibited without authorization. We recruit all kinds of technical teams all year round. If you are ready to change jobs and happen to like cloud music, then join us at grp.music-fe (at) corp.netease.com!

云音乐技术团队
3.6k 声望3.5k 粉丝

网易云音乐技术团队