Author: EllieSummer

After React V16.8, Function Component became the mainstream and React state management solutions underwent a huge change. Redux has always been the mainstream React state management solution. Although Redux provides a standard state management process, it has many problems, such as too many concepts, high cost to get started, repetitive boilerplate code, and the need to use it in combination with middleware.

A truly easy to use state management tool usually does not require too many complex concepts. Elegant simplicity became the trend after Hooks. Developers also tend to implement state management in a small, beautiful, low-cost way. Therefore, in addition to React Local State hooks, the community has also incubated a number of state management libraries, such as Unstate-Next, HOX, Zustand, Jotai, etc.

For state management, there is a classic scenario: implement a counter that increments the number when you click the + sign and decays it when you click the – sign. This is a primer on almost any state management library configuration.

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

React local state hooks

React provides some native hooks apis for managing state that are easy to understand and use. Counter functionality can be easily implemented using the native hooks method, which defines the state of the counter and the methods that change the state in the root component through the useState method and passes it layer by layer to the child components.

The 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} />
}
Copy the code

But this approach has serious drawbacks.

First, the business logic of the counter is heavily coupled to the components, and the logic needs to be abstractly separated to keep the logic and components pure.

Second, shared state across multiple components is passed through layers, with redundant code and the state of the root component growing into a behemoth.

unstated-next

React developers took the above two problems into account when designing the app, and provided their own solutions.

React Hooks Custom Hooks address logic reuse issues that previously could not be flexibly shared within Class 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 };
}
Copy the code

To avoid layering 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, you only need to store the state in StoreContext, and any child component of the Provider can use useContext to get the state in context.

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

const Timer = () = > {
    const store = React.useContext(StoreContext);
    // The render part of the component is omitted
}

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

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

This makes the code look cleaner.

However, it is still necessary to define a lot of contexts and reference them in child components, which is a bit tedious.

Therefore, the code can be further encapsulated by abstracting the steps of the Context definition and reference into the common method createContainer.

function createContainer(useHook) {
	/ / defines the context
    const StoreContext = React.createContext();
    
    function useContainer() {
        // The child refers to 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 }
}
Copy the code

The encapsulated createContainer returns two objects, Provider and useContainer. The Provider component can pass state to its children, which can get global state through the useContainer method. After modification, the code inside the component becomes very lean.

const Store = createContainer(useCount);

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

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

Thus, a basic state management scheme takes shape! The React state management library is small in size and has a simple API. The source code can be found here.

This scheme is also the realization principle of the state management library unstated-Next.

hox

Don’t count your chickens before they hatch. Although the unstated-next scheme was good, it also had drawbacks, which were also the two problems widely criticized by the React context.

  • Context requires the nesting of Provider components. Once you use multiple contexts in your code, you will create nesting hell, and the readability and purity of components will decrease, making component reuse more difficult.
  • Context may render unnecessarily. Whenever the value in the context changes, any child components that reference the context are updated.

Is there a way to solve these two problems? The answer is yes, there are already custom state management libraries that address both issues.

You can actually get some inspiration from the context solution. The flow of state management can be simplified into three models: Store (storing all state), Hook (abstractions common logic and changes state), and Component (components that use state).

If you want to customize the state management library, you can think about the relationship between these three things in mind.

  • Subscribe to update: Collect which components use Store when initializing Hook execution
  • Perceived change: The behavior in a Hook can change the state of the Store and must be perceived by the Store
  • Publish updates: When the Store changes, the Component updates that drive all subscription updates are required

With these three steps, state management is almost complete. With the general idea, the following can be implemented.

State initialization

First you need to initialize the state of the Store, which is the result returned by the Hook method execution. You also define an API method that lets child components get the state of the Store. Thus the model of the state management library is built.

As can be seen from the way the business code is used, the API is concise and avoids the nesting of Provider components.

// The framework of the state management library
function createContainer(hook) {
    const store = hook();
    // API methods provided to child components
    function useContainer() {
        const storeRef = useRef(store);
        return storeRef.current;
    }
    return useContainer;
}

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

const Timer = () = > {
	const store = useContainer();
    // The render part of the component is omitted
}
Copy the code

Subscribe to updates

In order to implement Store status update, can drive component update. You need to define a listeners collection that adds listener callbacks to the array during component initialization to update the status of the listeners.

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

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

    return useContainer;
}
Copy the code

So how do you drive component updates when the state is updated? Here you can use the useReducer hook to define an increment function, using the forceUpdate method to make the component brush.

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

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

State change awareness

The state change-driven component update section is complete. Now the big question is, how do you sense that the state has changed?

State changes are implemented within the useCount Hook function using the React native setState method, which can only be implemented within the React component. Therefore, it is easy to imagine that if you use a function component Executor to reference this Hook, then you can initialize the state within that component and be aware of the state change.

Given the versatility of the state management library, it is possible to build a React renderer for the React-Reconciler to mount Executor components, which can support react, ReactNative, and other frameworks separately.

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

// React component, which senses state changes in the hook
const Executor = (props) = > {
    const store = props.hook();
    const mountRef = useRef(false);
    
    // State initialization
    if(! mountRef.current) { props.onMount(store); mountRef.current =true;
    }

    // Once the store changes, the useEffect callback is executed
    useEffect(() = > {
        props.onUpdate(store); // Notify dependent components of the update once the state changes
    });

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

    // Pass the hook and update callback functions
    render(<Executor hook={hook} onMount={val= > store = val}  onUpdate={onUpdate} />);

    function useContainer() {}
    return useContainer;
}
Copy the code

Accurate update

Once a status change is sensed, components that previously subscribed to the update can be notified in the onUpdate callback to re-render, a collection of listeners traversed to execute the previously added update callbacks.

const onUpdate = (store) = > {
    for (const listener oflisteners) { listener(store); }}Copy the code

However, components can often rely on only one state in the Store, and updating all components is too harsh and leads to unnecessary updates that require accurate update rendering. Therefore, the component’s update callback can determine whether the state of the current dependency has changed to trigger the update.

// The useContainer API extension adds dependency properties
const store = useContainer('count'); // The component only relies on the store.count value

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

    // Components are updated only when dependencies change
    if (compare(newValue, oldValue)) {
        forceUpdate();
    }
    storeRef.current = newStore;
}
Copy the code

Complete the above steps, a simple and easy to use state management library to achieve! The source code can be seen here. The status update process is shown in the following figure.

Simple API, separation of logic and UI, state transfer across components, no redundant nested components, and accurate updates.

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

zustand

In this section on how to sense state changes, because the useCount function implements state changes by manipulating the React native hook method, we need to use Executor as an intermediate bridge to sense state changes.

However, this is actually a kind of grievance to complete the program, to complicate the program. Just imagine, if the method setState to change the state is provided by the state management library itself, then once the method is executed, it can sense the state change and trigger the subsequent comparison update operation, the whole process will be much simpler!

// Pass the setState method that changes the state to the hook
// This method can be used to detect state changes 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 };
}

Copy the code
function createContainer(hook) {
    let store;
    
    const setState = (partial) = > {
        const nexStore = partial(store);
		// Once the setState operation is executed and the state changes, onUpdate 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);
Copy the code

This approach is more sophisticated, making the implementation of the state management library simpler and smaller. The source code is available here.

This approach is the general rationale behind Zustand. The API is similar to Hooks, which are cheap to learn and easy to use.

conclusion

Starting from the realization of a counter scenario, this paper describes various state management schemes and concrete implementation. Different state management schemes have their own backgrounds and advantages and disadvantages.

But the idea behind custom state management libraries is pretty much the same. This is true of most of the active state management libraries in the open source community, but the main difference is how to perceive state changes.

By the end of this article, you already know how to manage state on React Hooks.

This article is published by NetEase Cloud Music Technology team. Any unauthorized reprinting of this article is prohibited. We recruit technical positions all year round. If you are ready to change your job and you like cloud music, join us!