Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React hook rendering an extra time

My code is causing an unexpected amount of re-renders.

function App() {    
    const [isOn, setIsOn] = useState(false)
    const [timer, setTimer] = useState(0)
    console.log('re-rendered', timer)

    useEffect(() => {
        let interval

        if (isOn) {
            interval = setInterval(() => setTimer(timer + 1), 1000)
        }

        return () => clearInterval(interval)
    }, [isOn])

    return (
      <div>
        {timer}
        {!isOn && (
          <button type="button" onClick={() => setIsOn(true)}>
            Start
          </button>
        )}

        {isOn && (
          <button type="button" onClick={() => setIsOn(false)}>
            Stop
          </button>
        )}
      </div>
    );
 }

Note the console.log on line 4. What I expected is the following to be logged out:

re-rendered 0

re-rendered 0

re-rendered 1

The first log is for the initial render. The second log is for the re-render when the "isOn" state changes via the button click. The third log is when setInterval calls setTimer so it's re-rendered again. Here is what I actually get:

re-rendered 0

re-rendered 0

re-rendered 1

re-rendered 1

I can't figure out why there is a fourth log. Here's a link to a REPL of it:

https://codesandbox.io/s/kx393n58r7

***Just to clarify, I know the solution is to use setTimer(timer => timer + 1), but I would like to know why the code above causes a fourth render.

like image 640
Adam Hartleb Avatar asked Feb 15 '19 18:02

Adam Hartleb


People also ask

Why is my React page rendering multiple times?

The reason why this happens is an intentional feature of the React. StrictMode . It only happens in development mode and should help to find accidental side effects in the render phase.

How do I stop Settimeout in React hooks?

To clear a timeout or an interval in React with hooks:Use the clearTimeout() or clearInterval() methods to remove the timeout when the component unmounts.

How do you avoid re rendering in React using hooks?

1. Memoization using useMemo() and UseCallback() Hooks. Memoization enables your code to re-render components only if there's a change in the props. With this technique, developers can avoid unnecessary renderings and reduce the computational load in applications.

Why does React keep Rerendering?

React's “main job” is to keep the application UI in sync with the React state. The point of a re-render is to figure out what needs to change.


1 Answers

The function with the bulk of what happens when you call the setter returned by useState is dispatchAction within ReactFiberHooks.js (currently starting at line 1009).

The block of code that checks to see if the state has changed (and potentially skips the re-render if it has not changed) is currently surrounded by the following condition:

if (
  fiber.expirationTime === NoWork &&
  (alternate === null || alternate.expirationTime === NoWork)
) {

My assumption on seeing this was that this condition evaluated to false after the second setTimer call. To verify this, I copied the development CDN React files and added some console logs to the dispatchAction function:

function dispatchAction(fiber, queue, action) {
  !(numberOfReRenders < RE_RENDER_LIMIT) ? invariant(false, 'Too many re-renders. React limits the number of renders to prevent an infinite loop.') : void 0;

  {
    !(arguments.length <= 3) ? warning$1(false, "State updates from the useState() and useReducer() Hooks don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().') : void 0;
  }
  console.log("dispatchAction1");
  var alternate = fiber.alternate;
  if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
    // This is a render phase update. Stash it in a lazily-created map of
    // queue -> linked list of updates. After this render pass, we'll restart
    // and apply the stashed updates on top of the work-in-progress hook.
    didScheduleRenderPhaseUpdate = true;
    var update = {
      expirationTime: renderExpirationTime,
      action: action,
      eagerReducer: null,
      eagerState: null,
      next: null
    };
    if (renderPhaseUpdates === null) {
      renderPhaseUpdates = new Map();
    }
    var firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
    if (firstRenderPhaseUpdate === undefined) {
      renderPhaseUpdates.set(queue, update);
    } else {
      // Append the update to the end of the list.
      var lastRenderPhaseUpdate = firstRenderPhaseUpdate;
      while (lastRenderPhaseUpdate.next !== null) {
        lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
      }
      lastRenderPhaseUpdate.next = update;
    }
  } else {
    flushPassiveEffects();

    console.log("dispatchAction2");
    var currentTime = requestCurrentTime();
    var _expirationTime = computeExpirationForFiber(currentTime, fiber);

    var _update2 = {
      expirationTime: _expirationTime,
      action: action,
      eagerReducer: null,
      eagerState: null,
      next: null
    };

    // Append the update to the end of the list.
    var _last = queue.last;
    if (_last === null) {
      // This is the first update. Create a circular list.
      _update2.next = _update2;
    } else {
      var first = _last.next;
      if (first !== null) {
        // Still circular.
        _update2.next = first;
      }
      _last.next = _update2;
    }
    queue.last = _update2;

    console.log("expiration: " + fiber.expirationTime);
    if (alternate) {
      console.log("alternate expiration: " + alternate.expirationTime);
    }
    if (fiber.expirationTime === NoWork && (alternate === null || alternate.expirationTime === NoWork)) {
      console.log("dispatchAction3");

      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      var _eagerReducer = queue.eagerReducer;
      if (_eagerReducer !== null) {
        var prevDispatcher = void 0;
        {
          prevDispatcher = ReactCurrentDispatcher$1.current;
          ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }
        try {
          var currentState = queue.eagerState;
          var _eagerState = _eagerReducer(currentState, action);
          // Stash the eagerly computed state, and the reducer used to compute
          // it, on the update object. If the reducer hasn't changed by the
          // time we enter the render phase, then the eager state can be used
          // without calling the reducer again.
          _update2.eagerReducer = _eagerReducer;
          _update2.eagerState = _eagerState;
          if (is(_eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // It's still possible that we'll need to rebase this update later,
            // if the component re-renders for a different reason and by that
            // time the reducer has changed.
            return;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          {
            ReactCurrentDispatcher$1.current = prevDispatcher;
          }
        }
      }
    }
    {
      if (shouldWarnForUnbatchedSetState === true) {
        warnIfNotCurrentlyBatchingInDev(fiber);
      }
    }
    scheduleWork(fiber, _expirationTime);
  }
}

and here's the console output with some additional comments for clarity:

re-rendered 0 // initial render

dispatchAction1 // setIsOn
dispatchAction2
expiration: 0
dispatchAction3
re-rendered 0

dispatchAction1 // first call to setTimer
dispatchAction2
expiration: 1073741823
alternate expiration: 0
re-rendered 1

dispatchAction1 // second call to setTimer
dispatchAction2
expiration: 0
alternate expiration: 1073741823
re-rendered 1

dispatchAction1 // third and subsequent calls to setTimer all look like this
dispatchAction2
expiration: 0
alternate expiration: 0
dispatchAction3

NoWork has a value of zero. You can see that the first log of fiber.expirationTime after setTimer has a non-zero value. In the logs from the second setTimer call, that fiber.expirationTime has been moved to alternate.expirationTime still preventing the state comparison so re-render will be unconditional. After that, both the fiber and alternate expiration times are 0 (NoWork) and then it does the state comparison and avoids a re-render.

This description of the React Fiber Architecture is a good starting point for trying to understand the purpose of expirationTime.

The most relevant portions of the source code for understanding it are:

  • ReactFiberExpirationTime.js
  • ReactFiberScheduler.js

I believe the expiration times are mainly relevant for concurrent mode which is not yet enabled by default. The expiration time indicates the point in time after which React will force a commit of the work at the earliest opportunity. Prior to that point in time, React may choose to batch updates. Some updates (such as from user interactions) have a very short (high priority) expiration, and other updates (such as from async code after a fetch completes) have a longer (low priority) expiration. The updates triggered by setTimer from within the setInterval callback would fall in the low priority category and could potentially be batched (if concurrent mode were enabled). Since there is the possibility of that work having been batched or potentially discarded, React queues a re-render unconditionally (even when the state is unchanged since the previous update) if the previous update had an expirationTime.

You can see my answer here to learn a bit more about finding your way through the React code to get to this dispatchAction function.

For others who want to do some digging of their own, here's a CodeSandbox with my modified version of React:

Edit static

The react files are modified copies of these files:

https://unpkg.com/react@16/umd/react.development.js
https://unpkg.com/react-dom@16/umd/react-dom.development.js
like image 110
Ryan Cogswell Avatar answered Oct 29 '22 09:10

Ryan Cogswell