Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React component sometimes renders twice with unchanged state

I am using Redux to subscribe to a store and update a component.

This is a simplified example without Redux. It uses a mock-up store to subscribe and dispatch to.

Please, follow the steps below the snippet to reproduce the problem.

Edit: Please skip to the second demo snippet under Update for a more concise and closer to real-life scenario. The question is not about Redux. It's about React's setState function identity causing re-render in certain circumstances even though the state has not changed.

Edit 2: Added even more concise demo under "Update 2".

const {useState, useEffect} = React;

let counter = 0;

const createStore = () => {
	const listeners = [];
	
	const subscribe = (fn) => {
		listeners.push(fn);
		return () => {
			listeners.splice(listeners.indexOf(fn), 1);
		};
	}
	
	const dispatch = () => {
		listeners.forEach(fn => fn());
	};
	
	return {dispatch, subscribe};
};

const store = createStore();

function Test() {
	const [yes, setYes] = useState('yes');
	
	useEffect(() => {
		return store.subscribe(() => {
			setYes('yes');
		});
	}, []);
	
	console.log(`Rendered ${++counter}`);
	
	return (
		<div>
			<h1>{yes}</h1>
			<button onClick={() => {
				setYes(yes === 'yes' ? 'no' : 'yes');
			}}>Toggle</button>
			<button onClick={() => {
				store.dispatch();
			}}>Set to Yes</button>
		</div>
	);
}

ReactDOM.render(<Test />, document.getElementById('root'));
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

What is happening

  1. ✅ Click "Set to Yes". Since the value of yes is already "yes", state is unchanged, hence the component is not re-rendered.
  2. ✅ Click "Toggle". yes is set to "no". State has changed, so the component is re-rendered.
  3. ✅ Click "Set to Yes". yes is set to "yes". State has changed again, so the component is re-rendered.
  4. ⛔ Click "Set to Yes" again. State has not changed, but the component is still re-rendered.
  5. ✅ Subsequent clicks on "Set to Yes" do not cause re-rendering as expected.

What is expected to happen

On step 4 the component should not be re-rendered since state is unchanged.

Update

As the React docs state, useEffect is

suitable for the many common side effects, like setting up subscriptions and event handlers...

One such use case could be listening to a browser event such as online and offline.

In this example we call the function inside useEffect once when the component first renders, by passing it an empty array []. The function sets up event listeners for online state changes.

Suppose, in the app's interface we also have a button to manually toggle online state.

Please, follow the steps below the snippet to reproduce the problem.

const {useState, useEffect} = React;

let counter = 0;

function Test() {
	const [online, setOnline] = useState(true);
	
	useEffect(() => {
		const onOnline = () => {
			setOnline(true);
		};
		const onOffline = () => {
			setOnline(false);
		};
		window.addEventListener('online', onOnline);
		window.addEventListener('offline', onOffline);
		
		return () => {
			window.removeEventListener('online', onOnline);
			window.removeEventListener('offline', onOffline);
		}
	}, []);
	
	console.log(`Rendered ${++counter}`);
	
	return (
		<div>
			<h1>{online ? 'Online' : 'Offline'}</h1>
			<button onClick={() => {
				setOnline(!online);
			}}>Toggle</button>
		</div>
	);
}

ReactDOM.render(<Test />, document.getElementById('root'));
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

What is happening

  1. ✅ The component is first rendered on the screen, and the message is logged in the console.
  2. ✅ Click "Toggle". online is set to false. State has changed, so the component is re-rendered.
  3. ⛔ Open Dev tools and in the Network panel switch to "offline". online was already false, thus state has not changed, but the component is still re-rendered.

What is expected to happen

On step 3 the component should not be re-rendered since state is unchanged.

Update 2

const {useState, useEffect} = React;

let counterRenderComplete = 0;
let counterRenderStart = 0;


function Test() {
  const [yes, setYes] = useState('yes');

  console.log(`Component function called ${++counterRenderComplete}`);
  
  useEffect(() => console.log(`Render completed ${++counterRenderStart}`));

  return (
    <div>
      <h1>{yes ? 'yes' : 'no'}</h1>
      <button onClick={() => {
        setYes(!yes);
      }}>Toggle</button>
      <button onClick={() => {
        setYes('yes');
      }}>Set to Yes</button>
    </div>
  );
}

ReactDOM.render(<Test />, document.getElementById('root'));

 
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

What is happening

  1. ✅ Click "Set to Yes". Since the value of yes is already true, state is unchanged, hence the component is not re-rendered.
  2. ✅ Click "Toggle". yes is set to false. State has changed, so the component is re-rendered.
  3. ✅ Click "Set to Yes". yes is set to true. State has changed again, so the component is re-rendered.
  4. ⛔ Click "Set to Yes" again. State has not changed, despite that the component starts the rendering process by calling the function. Nevertheless, React bails out of rendering somewhere in the middle of the process, and effects are not called.
  5. ✅ Subsequent clicks on "Set to Yes" do not cause re-rendering (function calls) as expected.

Question

Why is the component still re-rendered? Am I doing something wrong, or is this a React bug?

like image 874
Tigran Avatar asked Dec 17 '22 13:12

Tigran


2 Answers

The answer is that React uses a set of heuristics to determine whether it can avoid calling the rendering function again. These heuristics may change between versions and aren't guaranteed to always bail out when the state is the same. The only guarantee React provides is that it won't re-render child components if the state was the same.

Your rendering functions should be pure. Therefore, it shouldn't matter how many times they run. If you're calculating something expensive in your render function and are concerned about calling it more than necessary, you can wrap that calculation in useMemo.

Generally speaking, there's no use in "counting renders" in React. When exactly React calls your function is up to React itself, and the exact timing will keep changing between versions. It's not part of the contract.

like image 85
Dan Abramov Avatar answered Dec 31 '22 02:12

Dan Abramov


It seems like this is an expected behavior.

From the React docs:

Bailing out of a state update

If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with useMemo.

So, React does re-render the component on step 4 of the first demo and step 3 of the second one. Hence it executes all the code inside the function and it calls React.createElement() for each child of the component.

However it does not render any descendant of the component and does not fire the effects.

This is only true for function components. For a pure class component, the render method never gets called if the state has not changed.

There's nothing we can do to avoid the re-run. Memoizing the function with memo() will not help either, since it only checks for props changes, not the state. So we just have to take this situation into account.

This doesn't answer the question why and when React runs the function but bails out, and when it doesn't run the function at all. If you know the reason, please add your answer.

like image 33
Tigran Avatar answered Dec 31 '22 02:12

Tigran