Analysis of React hooks state management scheme

Author: EllieSummer

React v16. After 8, Function Component became the mainstream, and the scheme of react state management also changed greatly. Redux has always been the mainstream react state management solution. Although it provides a set of standardized state management process, it has many problems that people have been criticized: too many concepts, high starting cost, repeated template code, need to be used in combination with middleware, etc.

A truly easy-to-use state management tool often does not require too many complex concepts. After the birth of Hooks, elegant and concise code has become a trend. Developers also tend to use a small and beautiful scheme with low learning cost to realize state management. 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.

About state management, there is a very classic scenario: implement a counter. When clicking the + sign, the number will be increased by one, and when clicking the - sign, the value will be reduced by one. This is a standard entry case for almost all state management libraries.

Starting from the classic scenario of implementing "counter", this paper will gradually analyze the evolution process and implementation principle of React state management scheme in the Hooks era.

React local state hooks

React provides some native hooks API s for managing state, which are simple and easy to understand and very easy to use. The counter function can be easily realized by using 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} />
}

However, this method has serious defects.

Firstly, the business logic and components of the counter are seriously coupled, so it is necessary to abstract and separate the logic to maintain the purity of logic and components.

The internal state of components will gradually become "giant" through the transfer of multiple layers of components.

unstated-next

At the beginning of the design, React developers also considered 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 flexibly shared in Class Component components before.

Therefore, to solve the problem of business logic coupling, you can extract a custom counter hook useCount.

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

In order to avoid transferring state layer by layer between components, the context solution can be used. Context provides a way to share state between components without explicitly passing a prop at each level of the tree.

Therefore, you only need to store the state in the StoreContext, and any sub component 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);
    // The render part in the component is omitted first
}

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

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

This makes the code look refreshing.

However, when using, it is inevitable to define many contexts first and reference them in sub components, which is a little cumbersome.

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

function createContainer(useHook) {
    // Define context
    const StoreContext = React.createContext();
    
    function useContainer() {
        // Sub component reference 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 the createContainer is encapsulated, two objects Provider and useContainer will be returned. The Provider component can transfer the state to the sub component, which can obtain the global state through the useContainer method. After modification, the code in the component will become very streamlined.

const Store = createContainer(useCount);

// timer.js
const Timer = () => {
    const store = Store.useContainer();
    // The render part in the component is omitted first
}

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

In this way, a basic state management scheme is formed! With small volume and simple API, it can be said to be the minimum set of React state management library. Source code can be seen here.

This scheme is also a state management library unstated-next Implementation principle of.

hox

Don't be happy too soon. Although the unstated next scheme is good, it also has defects, which are also two problems widely criticized by React context:

  • context needs to nest Provider components. Once multiple contexts are used in the code, it will cause nesting hell, and the readability and purity of components will be reduced linearly, resulting in more difficult component reuse.
  • Context may cause unnecessary rendering. Once the value in the context changes, any subcomponent that references the context will be updated.

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

In fact, we can get some inspiration from the solution of context. The process of state management can be simplified into three models: Store (Store all States), Hook (Abstract public logic, change state), and Component (Component using state).

If you want to customize the state management library, you can think about the relationship between the three before?

  • Subscription update: when initializing and executing Hook, you need to collect which components use Store
  • Perceived change: the behavior in the Hook can change the state of the Store and be perceived by the Store
  • Publish update: once the Store changes, it needs to drive the Component update of all subscription updates

As long as these three steps are completed, state management is basically completed. With the general idea, we can realize it in detail below.

Status initialization

First, you need to initialize the state of the Store, that is, the result returned by the Hook method. At the same time, an API method is defined for sub components to obtain the status of the Store. In this way, the model of state management library is built.

From the usage of business code, we can see that the API is concise and avoids the nesting of Provider components.

// Framework of state management library
function createContainer(hook) {
    const store = hook();
    // API methods provided to subcomponents
    function useContainer() {
        const storeRef = useRef(store);
        return storeRef.current;
    }
    return useContainer;
}

// Business code usage: API simplicity
const useContainer = createContainer(useCount);

const Timer = () => {
    const store = useContainer();
    // The render part in the component is omitted first
}

Subscribe to updates

In order to drive component update when Store status is updated. You need to define a listener collection, add a listener callback to the array during component initialization, and update the subscription status.

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

    const listeners = new Set();    // Define callback collection
    
    function useContainer() {
        const storeRef = useRef(store);
    
        useEffect(() => {
            listeners.add(listener);  // Add callback during initialization and subscribe to updates
            
            return () =>  listeners.delete(listener) // Remove callback when component is destroyed
        },[])
        return storeRef.current;
    }

    return useContainer;
}

So how to drive component update after status update? Here, you can use useReducer hook to define a self increasing function, and use forceUpdate method to re brush the component.

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

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

Perceived state change

The part of status change driving component update has been completed. 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 the native setState method of React, and can only be executed in the React component. Therefore, it is easy to think that if a function component Executor is used to refer to this Hook, the state can be initialized and the state change can be sensed in this component.

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

// Construct react renderer
function render(reactElement: ReactElement) {
  const container = reconciler.createContainer(null, 0, false, null);
  return reconciler.updateContainer(reactElement, container);
}

// react component to sense the change of state in hook
const Executor = (props) => {
    const store = props.hook();
    const mountRef = useRef(false);
    
    // Status initialization
    if (!mountRef.current) {
        props.onMount(store);
        mountRef.current = true;
    }

    // Once the store is changed, the useEffect callback will be executed
    useEffect(() => {
        props.onUpdate(store); // Once the status changes, notify the dependent component of the update
    });

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

    // Callback function passing hook and update        
    render(<Executor hook={hook} onMount={val => store = val}  onUpdate={onUpdate} />);

    function useContainer() {}
    return useContainer;
}

Accurate update

Once the state change is sensed, the onUpdate callback can notify the previously subscribed components 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 only rely 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, you can judge whether the current dependent state changes in the update callback of the component, so as to decide whether to trigger the update.

// useContainer API extension adds dependency attribute
const store = useContainer('count'); // Component only depends on store Count value

// Update judgment in callback
function listener(newStore) {
    const newValue = newStore[dep];          
    const oldValue = storeRef.current[dep];

    // Only when the dependency changes will the component be updated
    if (compare(newValue, oldValue)) {
        forceUpdate();
    }
    storeRef.current = newStore;
}

After completing the above steps, a simple and easy-to-use state management library will be realized! Source code can see here.
The process of status update is shown in the following figure.

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

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

zustand

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

However, this is actually a grievance seeking solution, which has to be complicated. Imagine that if the state change method setState is provided by the state management library itself, once the method is executed, the state change can be sensed and the subsequent comparison and update operation can be triggered, and the overall process will be much simpler!

// Pass the stateful setState method to hook
// Once this method is executed in the hook, you can perceive the state change and get the latest state
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);
        // Once the setState operation is performed in the hook and the state changes, the onUpdate update will be triggered
        if(nexStore !== store){
            store = Object.assign({}, store, nexStore);
            onUpdate(store);
        }
    };
    // Pass the state changing method setState to the hook function
    store = hook(setState);
}

const useContainer = createContainer(useCount);

This scheme is more clever, which makes the implementation of the state management library more concise and clear, and the volume of the library will be much smaller. Source visible here.

This scheme is zustand The general principle behind it. Although developers need to be familiar with the corresponding writing method first, the API is similar to Hooks, with low learning cost and easy to use.

summary

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

However, the design idea of user-defined state management library is the same. At present, most of the active state management libraries 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 the state under React Hooks, so hurry up!

This article is published by Netease cloud music technology team. It is prohibited to reprint the article in any form 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, join us GRP music-fe (at) corp.netease. com!

Keywords: React

Added by Q on Fri, 18 Feb 2022 11:54:53 +0200