1 the introduction

React Hooks are gradually being adopted by front-end teams in the country, but the data flow solution based on Hooks is not yet fixed. We have “100” options like this, each with its own pros and cons, and it’s hard to choose between them.

This week I’m going to delve into my understanding of the Hooks data flow, and I believe that after reading this article you can see the essence of the Hooks data flow solution.

2. Intensive reading

Talking about data flow, let’s start with the base scenario that is least divisive.

Single component data flow

The simplest data flow for a single component must be useState:

function App() {
  const [count, setCount] = useState();
}
Copy the code

The use of useState within components is uncontroversial, so the next topic must be sharing data flows across components.

Components share data flows

The simplest solution across components is useContext:

const CountContext = createContext();

function App() {
  const [count, setCount] = useState();
  return (
    <CountContext.Provider value={{ count.setCount}} >
      <Child />
    </CountContext.Provider>
  );
}

function Child() {
  const { count } = useContext(CountContext);
}
Copy the code

The usage is official API and obviously there is no dispute about it, but the problem is that data and UI are decoupled. This problem is unstated- Next has worked out a solution for you.

Data flow is decoupled from components

Unstated – Next can help you separate out the data defined in the App in the above example and form a custom data management Hook:

import { createContainer } from "unstated-next";

function useCounter() {
  const [count, setCount] = useState();
  return { count, setCount };
}

const Counter = createContainer(useCounter);

function App() {
  return (
    <Counter.Provider>
      <Child />
    </Counter.Provider>
  );
}

function Child() {
  const { count } = Counter.useContainer();
}
Copy the code

The data is decoupled from the App, and now Counter is no longer bound to App, it can be bound to other components.

At this time, the performance problems slowly surfaced, the first is useState can not merge the update problem, we naturally thought of using useReducer to solve the problem.

Merge update

UseReducer allows users to merge and update data. This is also the official React API.

import { createContainer } from "unstated-next";

function useCounter() {
  const [state, dispath] = useReducer(
    (state, action) = > {
      switch (action.type) {
        case "setCount":
          return {
            ...state,
            count: action.setCount(state.count),
          };
        case "setFoo":
          return {
            ...state,
            foo: action.setFoo(state.foo),
          };
        default:
          return state;
      }
      return state;
    },
    { count: 0.foo: 0});return { ...state, dispatch };
}

const Counter = createContainer(useCounter);

function App() {
  return (
    <Counter.Provider>
      <Child />
    </Counter.Provider>
  );
}

function Child() {
  const { count } = Counter.useContainer();
}
Copy the code

In this case, even if we need to update count and foo at the same time, we can merge the update by abstraction into a reducer.

However, there are performance issues:

function ChildCount() {
  const { count } = Counter.useContainer();
}

function ChildFoo() {
  const { foo } = Counter.useContainer();
}
Copy the code

When foo is updated, both ChildCount and ChildFoo are executed, but ChildCount does not apply to foo. The reason for this is that the data stream provided by counter.usecontainer is a whole reference, and a change in the reference to its child foo causes the whole Hook to be re-executed, and all components that reference it to be re-rendered.

At this point we found that we could use Redux useSelector for on-demand updates.

According to the need to update

First, we use Redux to transform the data stream:

import { createStore } from "redux";
import { Provider, useSelector } from "react-redux";

function reducer(state, action) {
  switch (action.type) {
    case "setCount":
      return {
        ...state,
        count: action.setCount(state.count),
      };
    case "setFoo":
      return {
        ...state,
        foo: action.setFoo(state.foo),
      };
    default:
      return state;
  }
  return state;
}

function App() {
  return (
    <Provider store={store}>
      <Child />
    </Provider>
  );
}

function Child() {
  const { count } = useSelector(
    (state) = > ({ count: state.count }),
    shallowEqual
  );
}
Copy the code

UseSelector can update Child when count changes and not Foo when it changes, which is close to a more desirable performance goal.

However, useSelector only prevents the component from refreshing when the computed result does not change, but does not guarantee that the reference returning the result does not change.

Prevent data references from changing frequently

For the above scenario, the reference to get count is constant, but not necessarily for other scenarios.

Here’s an example:

function Child() {
  const user = useSelector((state) = > ({ user: state.user }), shallowEqual);

  return <UserPage user={user} />;
}
Copy the code

If the user object reference changes every time the data stream is updated, then shallowEqual does not work. The result is that the reference will still change, but the rerender will be less frequent:

function Child() {
  const user = useSelector(
    (state) = > ({ user: state.user }),
    // Render only when the user value changes
    deepEqual
  );

  // But the user reference will still change

  return <UserPage user={user} />;
}
Copy the code

Do you think the user reference won’t change without rerendering being triggered by deepEqual? The answer is yes, because the User object changes every time the data stream is updated. UseSelector does not trigger re-rendering under the effect of deepEqual, but because the global Reducer hides the re-rendering of the component itself, the user reference it gets will change constantly.

Therefore useSelector deepEqual must be used in conjunction with useDeepMemo to ensure that the user reference does not change frequently:

function Child() {
  const user = useSelector(
    (state) = > ({ user: state.user }),
    // Render only when the user value changes
    deepEqual
  );

  const userDeep = useDeepMemo((a)= > user, [user]);

  return <UserPage user={user} />;
}
Copy the code

Of course, this is an extreme case, so whenever you see deepEqual working with useSelector at the same time, ask yourself if the reference to the value it returns changes unexpectedly.

Cache query function

For extreme scenarios, even when the number of rerenders and the maximum number of references returned remain constant, there may be performance problems. The final piece of performance problems lies in the query function.

In the above example, the query function is simple, but not if the query function is very complex:

function Child() {
  const user = useSelector(
    (state) = > ({ user: verySlowFunction(state.user) }),
    // Render only when the user value changes
    deepEqual
  );

  const userDeep = useDeepMemo((a)= > user, [user]);

  return <UserPage user={user} />;
}
Copy the code

Let’s assume that the verySlowFunction traverses 1000 components in the canvas n ^ 3 times. That component’s rerender time consumption is nothing compared to the query time. We need to consider caching the query function.

One way is to use ResELECT to cache against parameter references.

Imagine if the reference to state.user changes infrequently, but verySlowFunction is very slow. Ideally, verySlowFunction should be re-executed after the reference changes, but in the example above, UseSelector didn’t know it could be optimized this way, so it stupidly repeated verySlowFunction every time it rendered, even if state.user didn’t change.

At this point we tell the reference whether the state. User changes is the key to the re-execution:

import { createSelector } from "reselect";

const userSelector = createSelector(
  (state) = > state.user,
  (user) => verySlowFunction(user)
);

function Child() {
  const user = useSelector(
    (state) = > userSelector(state),
    // Render only when the user value changes
    deepEqual
  );

  const userDeep = useDeepMemo((a)= > user, [user]);

  return <UserPage user={user} />;
}
Copy the code

In the example above, the userSelector created by createSelector is cached layer by layer, and if the state.user reference returned by the first parameter does not change, it returns the result of the last execution until its application changes.

This also illustrates the importance of functions remaining idempotent, as this caching would not be possible if verySlowFunction were not strictly idempotent.

It looks nice, but in practice you might find it’s not that nice, because all of these examples are based on Selector not relying on external variables at all.

Cache queries with external variables

If we want to query users from different locales and need to pass areaId to identify them, we can split it into two Selector functions:

import { createSelector } from "reselect";

const areaSelector = (state, props) = > state.areas[props.areaId].user;

const userSelector = createSelector(areaSelector, (user) =>
  verySlowFunction(user)
);

function Child() {
  const user = useSelector(
    (state) = > userSelector(state, { areaId: 1 }),
    deepEqual
  );

  const userDeep = useDeepMemo((a)= > user, [user]);

  return <UserPage user={user} />;
}
Copy the code

So in order not to call createSelector within a component function, we need to abstract as much as possible the use of external variables into a general Selector and use it as a first step in createSelector.

However, the cache fails when userSelector is provided to multiple components. The reason is that we only create one Selector instance, so the function needs to wrap another layer of hierarchy:

import { createSelector } from "reselect";

const userSelector = (a)= >
  createSelector(areaSelector, (user) => verySlowFunction(user));

function Child() {
  const customSelector = useMemo(userSelector, []);

  const user = useSelector(
    (state) = > customSelector(state, { areaId: 1 }),
    deepEqual
  );
}
Copy the code

Therefore, useMemo and useSelector should be used together for the link combining external variables. UseMemo handles the reference cache dependent on external variables, and useSelector handles the Store reference cache.

3 summary

The data flow scheme based on Hooks is not perfect, and as I write this I feel that it is “shallow in, deep out”, simple scenarios are easy to understand, and the schemes become more and more complex as the scenarios become more complex.

However, this Immutable approach to data stream management gives developers very free control over the cache. Once the above concepts are understood, it is possible to develop a very “expected” data cache management model, which can be maintained with care and in good order.

The address for discussion is: Intensive reading of React Hooks data stream · Issue #242 · dt-fe/weekly

If you’d like to participate in the discussion, pleaseClick here to, with a new theme every week, released on weekends or Mondays. Front end Intensive Reading – Helps you filter the right content.

Pay attention to the front end of intensive reading wechat public account

Copyright Notice: Freely reproduced – Non-commercial – Non-derivative – Remain signed (Creative Commons 3.0 License)