Teach you to write a React state management library

Keywords: Javascript Front-end React react-hooks

Since the implementation of React Hooks, Redux is out of place as a state management scheme. Dan Abramov mentioned "You might not need Redux" a long time ago. Developers must write a lot of "pattern code". Cumbersome and repetition are not tolerated by developers. In addition to the fact that the concepts such as actions/reducers/store are not friendly to novices, the biggest disadvantage is that its support for typescript types is too poor, which is unacceptable in large projects.

By summarizing the advantages and disadvantages of Redux, we can write a state management library ourselves. The objectives to be achieved this time are as follows:

  1. typescript type should be perfect
  2. Simple enough with fewer concepts
  3. Match with React Hooks

Therefore, the premise of reading this document is to have a certain concept of React Hooks, typescript, etc. OK, let's start.

thinking

At present, many popular state management libraries are too complex, mixed with a large number of concepts and APIs. We need to plan how to implement it. State management is the ultimate performance of state improvement. Our goal is to be simple enough with fewer APIs.
Think about whether we can consider using Context for penetration management and using the most basic useState and other hooks for state storage. Then try it.

These are the three simplest functional component demos, which we use to test:

function App() {
  return <Card />;
}

function Card() {
  return <CardBody />;
}

function CardBody() {
  return <div>Text</div>;
}

realization

We define Context, a very basic state model

// Describes the type of Context
interface IStoreContext {
  count: number;
  setCount: React.Dispatch<React.SetStateAction<number>>;
  increment: () => void;
  decrement: () => void;
}

// Create a Context without default value. Here, assertion is used for demonstration convenience
export const StoreContext = React.createContext<IStoreContext>(undefined as unknown as IStoreContext);

And define the basic state and cooperate with the Context

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

  // Package Provider, all sub components can get the value of context
  return (
    <StoreContext.Provider value={{ count, setCount, increment, decrement }}>
      <Card />
    </StoreContext.Provider>
  );
}

Next, we use this Context in CardBody to make it penetrate the value

function CardBody() {
  // Gets the state in the outer container
  const store = React.useContext(StoreContext);

  return <div onClick={store.increment}>Text {store.count}</div>;
}

In this way, the simplest code for penetrating state management is written. Have you found the problem? The business logic of the status is written in the App component. The code coupling is too high! Let's sort it out. We need to pull out the status of the App through a custom hook to maintain the purity of logic and components.

// Manage the status in the App with a custom hook, separating the logic from the performance of components
function useStore() {
  // Define status
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  return {
    count,
    setCount,
    increment,
    decrement,
  };
}

Use this hook in the App

function App() {
  const store = useStore();

  return (
    <StoreContext.Provider value={store}>
      <Card />
    </StoreContext.Provider>
  );
}

Now, it's much more comfortable. Logic is controlled in a separate hook, which has the characteristics of high cohesion. It may not be enough to think about it. The logic of useStore and StoreContext is not cohesive enough. Continue:

Pull useStore and StoreContext.Provider into one component

const Provider: React.FC = ({ children }) => {
  const store = useStore();
  return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
};

Let's look at the App component. Is it very clear?

function App() {
  return (
    <StoreProvider>
      <Card />
    </StoreProvider>
  );
}

Well, we can encapsulate this pattern into a method to create Context and Provider through factory pattern.

// Pass in the custom Hook through the parameter
// Define generic description Context shape
export function createContainer<Value, State = void>(useHook: (initialState?: State) => Value) {
  const Context = React.createContext<Value>(undefined as unknown as Value);

  const Provider: React.FC<{ initialState?: State }> = ({ initialState, children }) => {
    // Use external incoming hook
    const value = useHook(initialState);
    return <Context.Provider value={value}>{children}</Context.Provider>;
  };

  return { Provider, Context };
}

OK, a simple state management is taking shape. OK, let's try and move the previously defined useStore code into createContainer

export const BaseStore = createContainer(() => {
  // Define status
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  return {
    count,
    setCount,
    increment,
    decrement,
  };
});

Replace with BaseStore exported Provider in App

function App() {
  return (
    <BaseStore.Provider>
      <Card />
    </BaseStore.Provider>
  );
}

The Context exported from BaseStore is used in CardBody. Because generics are used in the definition, the shape of the current store can be perfectly recognized here, so it has the intelligent prompt of the editor

function CardBody() {
  const store = React.useContext(BaseStore.Context);

  return <div onClick={store.increment}>Text {store.count}</div>;
}

Congratulations, you have created your own state management library. Let's give it a name unstated-next

adjustment

However, there is always a trade-off between convenience and performance. There is no doubt that success is Context and failure is Context. Because of its penetration indifference update feature, it will invalidate all React.memo optimizations. It is extremely unacceptable that setState almost makes the whole project follow rerender at one time. Because the custom Hook returns a brand-new object every time it executes, the Provider will accept the brand-new object every time. All sub components using this Context are updated together, resulting in meaningless loss calls.

Do you have a plan? Think about it. There are always more ways than difficulties. We can optimize the features of Context context and abandon the features that lead to re rendering (that is, a fixed reference is passed to him every time). In this case, if the status changes and the sub components to be updated do not follow the update, what can I do? What can I do to trigger the rerender? The answer is setState. We can promote the setState method to the Context and let the container schedule the call update.

// In the createContainer function

// First, we can set the Context to not trigger render
// Here, the function return value of the second parameter of createContext is 0, that is, render is not triggered
// Note: this API is informal. Of course, useRef can also be used to forward the value of the whole Context to make it immutable
// Using informal API s is just to avoid ref s and reduce code 😄
const Context = React.createContext<Value>(undefined as unknown as Value, () => 0);

Now that the Context is immutable, how to implement the update logic? The idea can be as follows: we add a listener to the Context when the sub component is mounted, remove it when unMount, and call this listener to rerender when the Context is updated.

Declare a Context to put the listener of these subcomponents

// In the createContainer function
const ListenerContext = React.createContext<Set<(value: Value) => void>>(new Set());

Now the sub component needs such a hook. If you want to select some states in the store to use, there is no relevant state change, and you don't need to notify me to update.

Then we'll call it useSelector to monitor which value changes can make this component rerender.

Function types can be defined as follows: manually specify the value to be monitored and return the value by passing in a function

// In the createContainer function

function useSelector<Selected>(selector: (value: Value) => Selected): Selected {}

Let's implement this useSelector. The first is to trigger the rerender method. Here, the reducer is used to make its internal self increment, and there is no need to pass parameters when calling

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

Here, we need to communicate with the Context in the container to get all the States and pass them to the selector function

// The Context here does not have the feature of triggering updates
const value = React.useContext(Context);
const listeners = React.useContext(ListenerContext);

// Call the method to get the selected value
const selected = selector(value);

Create a listener function, forward it through Ref, and provide the selected state to the listener function for use, so that this function can get the latest state,

const StoreValue = {
  selector,
  value,
  selected,
};
const ref = React.useRef(StoreValue);

ref.current = StoreValue;

Implement this listener function

function listener(nextValue: Value) {
  try {
    const refValue = ref.current;
    // If the comparison values are the same, render is not triggered
    if (refValue.value === nextValue) {
      return;
    }
    // Compare the selected values lightly, and render will not be triggered if it is the same
    const nextSelected = refValue.selector(nextValue);
    //
    if (isShadowEqual(refValue.selected, nextSelected)) {
      return;
    }
  } catch (e) {
    // ignore
  }
  // After running here, the value has changed and render is triggered
  forceUpdate();
}

We need to add / remove the listener when the component is mounted / unmount

React.useLayoutEffect(() => {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}, []);

The complete implementation is as follows:

function useSelector<Selected>(selector: (value: Value) => Selected): Selected {
  const [, forceUpdate] = React.useReducer((c) => c + 1, 0);

  const value = React.useContext(Context);
  const listeners = React.useContext(ListenerContext);

  const selected = selector(value);

  const StoreValue = {
    selector,
    value,
    selected,
  };
  const ref = React.useRef(StoreValue);

  ref.current = StoreValue;

  React.useLayoutEffect(() => {
    function listener(nextValue: Value) {
      try {
        const refValue = ref.current;
        if (refValue.value === nextValue) {
          return;
        }
        const nextSelected = refValue.selector(nextValue);
        if (isShadowEqual(refValue.selected, nextSelected)) {
          return;
        }
      } catch (e) {
        // ignore
      }
      forceUpdate();
    }

    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  }, []);
  return selected;
}

With a selector. Finally, let's rewrite the Provider

// In the createContainer function

const Provider: React.FC<{ initialState?: State }> = ({ initialState, children }) => {
  const value = useHook(initialState);
  // Use Ref so that the listener Context does not have the ability to trigger updates
  const listeners = React.useRef<Set<(listener: Value) => void>>(new Set()).current;

  // Each time the setState in useHook will update this component and make the listeners trigger the call, so as to render the sub component that changes the state
  listeners.forEach((listener) => {
    listener(value);
  });
  return (
    <Context.Provider value={value}>
      <ListenerContext.Provider value={listeners}>{children}</ListenerContext.Provider>
    </Context.Provider>
  );
};

be accomplished! The new objects returned by useSelector will be shallow compared like React.memo. API usage is similar to react Redux, with no learning cost. Let's look at the usage

function CardBody() {
  // Once the count is changed, this component triggers rerender
  // If it's troublesome here, you can use the pick function in lodash
  const store = BaseStore.useSelector(({ count, increment }) => ({ count, increment }));

  return <div onClick={store.increment}>Text {store.count}</div>;
}

It is worth noting that the value returned in the createContainer function cannot be regenerated every time render. Let's modify the BaseStore

export const BaseStore = createContainer(() => {
  // Define status
  const [count, setCount] = React.useState(0);

  // Replace the two previously defined functions with the useMethods package to ensure that the function references of increment and increment remain unchanged
  const methods = useMethods({
    increment() {
      setCount(count + 1);
    },
    decrement() {
      setCount(count - 1);
    },
  });

  return {
    count,
    setCount,
    ...methods,
  };
});

The useMethods Hook here is analyzed in a previous article to replace useCallback. See Heo for the source code.

To add icing on the cake, useSelector can be combined with lodash.picker to encapsulate a more common API, named usePicker

// In the createContainer function

function usePicker<Selected extends keyof Value>(selected: Selected[]): Pick<Value, Selected> {
  return useSelector((state) => pick(state as Required<Value>, selected));
}

Try the effect:

function CardBody() {
  const store = BaseStore.usePicker(['count', 'increment']);

  return <div onClick={store.increment}>Text {store.count}</div>;
}

summary

Well, this is what I wrote about state management at that time. Have you learned it? See source code Heo It is also the state management we are using. It is light enough, works with Hooks, perfectly supports TS, and has little difficulty in modifying the original code. At present, it has been running stably in the production environment for more than a year. The project with the highest complexity is to render more than 2000 recursive components at one time, and the performance remains very excellent. Welcome, Star.

Welcome to WeChat front end official account, and we will send you ideas for various components.

Posted by admin101 on Fri, 12 Nov 2021 19:03:17 -0800