Helux 2 is released to help you deeply understand the double calling mechanism of side effects in react18

React 18 adds a heuristic concurrent rendering mechanism. The side effect function may be called multiple times due to component re-rendering. In order to help users clarify the correct way to use side effects, when StrictMode is enabled in development mode, side effects will be deliberately called twice. function to achieve the effect of checking user logic, but this has also caused trouble to some upgraded users. This article will discuss how helux avoids this problem.

Introduction to helux

helux is a react state library that focuses on lightweight, high performance, and zero-cost access. Your application only needs to be replaced useStatewith useShared, and then you can improve the react local state to a global shared state without modifying a single line of other code. Effect, visit this online example to learn more.

import React from 'react';
+ import { createShared, useShared } from 'helux';
+ const { state: sharedObj } = createShared({a:100, b:2});

function HelloHelux(props: any) {
-  const [state, setState] = React.useState({ a: 100, b: 2 });
+  const [state, setState] = useShared(sharedObj);

   // The current component only relies on a change to trigger re-rendering
   // helux will dynamically collect the latest dependencies of each round of rendering of the current component to ensure accurate updates.
   return <div>{state.a}</div>;
}

The default shared object is non-responsive, and users are expected to change the state in a react-like manner. If the user sets it enableReactiveto true, a responsive object can be created.

const { state, setState } = createShared({ a: 100, b: 2 }, true);
// or
const { state, setState } = createShared({ a: 100, b: 2 }, { enableReactive: true });

// Will update all component instances that use the `sharedObj.a` value 
sharedObj. a ++; 
 setState ({ a : 1000 });

What 2.0 brings

2.0The version has made the following three adjustments:

Simplify API naming

The original useSharedObjectAPI has been re-exported into a more streamlined one useSharedto createSharedimprove the user’s writing efficiency and reading experience.

Added new signal record (experimental)

helux-signalSignal-related record data has been added internally to prepare the relevant infrastructure for future releases (a react signal pattern implementation library based on helux encapsulation) helux-signal. It is still in the prototype stage and will release a version experience at the appropriate time beta.

When not using signals, you need createSharedand useSharedto work together createSharedto create a shared state and useSharedbe responsible for consuming the shared state. It returns a specific readable state value and update function.

const { state : sharedObj } = createShared({a: 100 , b: 2 }); // Create
function HelloHelux(props: any) {
  const [state, setState] = useShared(sharedObj); // 使用
  // render logic ...
}  

When using signals, you only need to call helux-signalan interface createSignalto complete the creation of the state, and then the component can skip useSharedthe hook function and read the shared state directly.

It should be noted that at present, helux 2 has only completed the relevant infrastructure internally. The upper layer is responsible for the specific implementation of helux-signal, which is still in the experimental stage. At this stage, the community already has the signals-react library provided by preact to support the development of react components in signal mode.

An example of a complete expected helux-signaldevelopment of react-based components is as follows:

import { createSignal, comp } from 'helux-signal';

const { state , set State } = createSignal({a: 100 , b: 2 }); // Create signal
// The following two methods will trigger component re-rendering
state .a++;
 set State({a: 1000 });

// <HelloSignal ref={ref} id="some-id" /> 
const HelloSignal = comp((props, ref)=>{ // Create a react component that can read signals
    return <div> { state .a}</div>; // The dependency of the current component is state .a    
})

Added useEffect, useLayoutEffect

The v2 version adds useEffecttwo useLayoutEffectnew interfaces, which are also the two interfaces that this article will focus on. Why are heluxthese two interfaces provided to replace the native interfaces? Let’s look at the following content for detailed explanation.

side effects of react18

React 18 adds a heuristic concurrent rendering mechanism. The side effect function may be called multiple times due to component re-rendering. In order to help users discover possible problems caused by incorrect use of side effects (such as forgetting to do cleanup behavior), it is enabled in development mode. In StrictMode, the side effect function will be deliberately called twice to achieve the effect of checking the user’s logic, and it is expected that the user will correctly understand the side effect function.

What actual situation will cause multiple mounting behaviors? The new document specifically mentions an example. Since in React 18, React will separate the component’s state and uninstallation behavior (uninstallation controlled by non-user code), that is, the component will remain in its uninstalled state, and will be restored internally by React when it is mounted again. For example This feature is required for off-screen rendering scenes.

The trouble with double calls

However, this move has also caused trouble for some upgrade users. Take the following example as an example:

function UI(props: any) {
  useEffect(() => {
    console.log("mount");
    return () => {
      console.log("clean up");
    };
  }, []);
}

In strcit mode print as follows

mount
clean up
mount

After the user actually uninstalled the component, there was another clean upprinting, which made many users mistakenly think it was a bug. They went to the react warehouse to file an issue and described that useEffect was called twice after upgrading to 18. Later, react officials specifically explained that this problem was a normal phenomenon and provided assistance. The user has an unreasonable side-effect function and a deliberate double-calling mechanism.

However, in some scenarios, users really expect that only one call will be made during development (such as component data initialization), so there are various ways to combat double calls as follows.

RemoveStrcitMode

The simplest and crudest way is to remove the package at the root component StrcitModeto completely block this double calling behavior.

root.render(
-  <React.StrictMode>
    <App />
-  </React.StrictMode>
);

Users may only need unparalleled calls in some places, and double calls in other places to check the correctness of side effects, but this is a one-shot kill for all scene behaviors and is not very general.

Partially wrapped StrcitMode

StrcitModeIn addition to wrapping the root component, it also supports wrapping any sub-component. Users can wrap it wherever needed.

 <React.StrictMode><YourComponent /></React.StrictMode>

Compared with global removal, this method is gentler, but wrapping StrictMode is a forced behavior. It needs to be exported from the code to arrange where it needs to be wrapped and where it does not need to be wrapped. It is more troublesome. Is there a way to wrap the root component StrcitModeand partially shield the double What about the way to call the mechanism? Users started to start from the code level, to be precise, they useEffectstarted from the callback.

Use useRef to mark execution status

The general idea is to use useRefstatus to record whether a side-effect function has been executed, so that the second call is ignored.

function Demo(props: any) {
  const isCalled = React.useRef(false);

  React.useEffect(() => {
    if (isCalled.current === false) {
      await somApi.fetchData();
      isCalled.current = true;
    }
  }, []);
}

This has certain limitations, that is, if dependencies are added, isCalledit cannot be controlled. According to thinking, the side effects of the cleanup function will be set isCalled.currentto false. In this way, when the id value is changed during the lifetime of the component, even if there are double calls, it will not Print twicemock api fetch

 React.useEffect(() => {
    if (isCalled.current === false) {
        isCalled.current = true;
      console.log('mock api fetch');
        return ()=>{
         isCalled.current = false;
         console.log('clean up');
        };
    }
  }, [id]); // When the id changes, initiate a new request

However, as written above, two calls still occur when the component is mounted for the first time, and the printing order is

mock api fetch
clean up
mock api fetch

Is there a truly perfect solution so that StricModewhen wrapping based on the root component, the sub-component will only be called once for initial mounting and lifetime side effects? Next, let us helux provide you useEffectwith a complete solution to this problem.

Use helux’s useEffect

We only need to core understand the reason for the double call of react: to separate the component uninstallation and state, that is, when the component is mounted again, the existing state of the existence period will be restored . Since there is a restoration process, the starting point is very easy, mainly Observe the running log at the moment the component is restored to look for patterns.

First mark a sequence of auto-incrementing ids as the component example id, and observe which instance the mounting behavior is for.

let insKey = 0;
function getInsKey() {
  insKey++;
  return insKey;
}

function TestDoubleMount() {
  const [insKey] = useState(() => getInsKey());
  console.log(`TestDoubleMount ${insKey}`);
  React.useEffect(() => {
    console.log(`mount ${insKey}`);
    return () => console.log(`clean up ${insKey}`);
  }, [insKey]);
  return <div>TestDoubleMount</div>;
}

You can observe the log as shown below. You can find that the gray print TestDoubleMountis the second call intentionally initiated by react. The side effects are all for example No. 2. No. 1 is discarded by react as a redundant call.

Since the id is auto-incremented, react will deliberately initiate two calls to the same component, discard the first one and repeat the side effects for the second call (mount–>clean–>mount —> after the component is uninstalled clean), then when the second side effect is executed, we only need to check whether there is a side effect record in the previous example, and record the number of executions of the second side effect. It is easy to shield the side effects of the second mode, that is (mount –>clean–>mount —> clean after component uninstallation) has been modified to (mount —> clean after component uninstallation), and the set clean is executed when the component is actually uninstalled.

The pseudo code is as follows

function  mayExecuteCb ( insKey: number , cb: EffectCb ) {
   markKeyMount (insKey); // Record the current instance id and the number of mounts 
  const curKeyMount = getKeyMount (insKey); // Get the current instance mount information 
  const pervKeyMount = getKeyMount (insKey - 1 ); // Get the mount information of the previous instance 
  if (!pervKeyMount) { // If the previous example has no mount information, it is a double call behavior 
    if (curKeyMount && curKeyMount. count > 1 ) { // The second time of the current instance Mounting is executing the user's side effect function 
      const cleanUp = cb ();
       return  () => {
         clearKeyMount (insKey); // Clean up the current instance mounting information 
        cleanUp && cleanUp (); // Return the cleanup function
      };
    }
  }
}

On this basis, encapsulating one useEffectfor the user can achieve the purpose we mentioned above: StricModewhen wrapping based on the root component, the sub-component’s initial mounting and lifetime side effects will only occur once.

function useEffect(cb: EffectCb, deps?: any[]) {
  const [ insKey ] = use State(() => get InsKey() ); // Write as a function to avoid key increment overhead 
  React . use Effect(() => {
    return mayExecuteCb(insKey, cb);
  }, deps);
}

If you are interested in useEffectthe specific implementation, you can view the warehouse code

useEffectNow you can use the heluxexported one just like the native one useEffect, while enjoying the benefit of not requiring double call detection in some scenarios.

import { useEffect } from 'helux';

useEffect(() => {
  console.log('mock api fetch', id);
  return () => {
    console.log('mock clean up');
  };
}, [id]); // When the id changes, initiate a new request