I've handpicked some representative React quizzes from bigfrontend.dev and compiled the key concepts they cover.
As a React developer, mastering the knowledge shared in this article will elevate your understanding of the framework, enabling you to develop more seamlessly.
This article is constantly being updated and refined, and I plan to incorporate more in-depth information about React's underlying mechanisms to offer even more insightful answers.
-
- SetState acts in an asynchronous way in React
-
Practice Questions: 16
-
- For useState, the callback function we pass to it will only be called once during mounting (first time rendering).
-
Practice Questions: 9
-
- Triggering setState in a component can cause this component and its child components to be re-rendered.
-
Practice Questions: 9
-
- A React useEffect hook will call the cleanup function each time before the Effect runs again, before the related component unmount.
-
Practice Questions: 21
-
- For useEffect(), callbacks inside it are executed when its dependency changed. If the dependency of useEffect() is an empty array, code inside useEffect will only be called after first rendering.
-
Practice Questions: 12
-
- Changes made to ref.current do not cause React to re-render.
-
Practice Questions: 21
-
- Before finishing rendering, ref.current of a DOM element will not be updated.
-
Practice Questions: 13
-
- In React, if there is a tiny little change in the context provider, components sharing the same context will get re-rendered.
-
Practice Questions: 9
-
- useLayoutEffect runs synchronously immediately after React has performed all DOM mutations.
-
Practice Questions: 19
-
- Memo fails when its props changed.
-
- To avoid unnecessary re-renders, React groups multiple state updates into one single re-render.
- Before version 18, React does not batch updates outside React event handlers.
-
- Children components passed as props to a component are not supposed to be re-rendered, unless the component's props are changed.
- Props changes are detected by shallow comparison.
-
- FlushSync causes an immediately re-render for the current component, and makes setState() run synchronously.
- FlushSync doesn’t break the guarantee of internal consistency
-
Practice Questions: 17
-
- If a children component suspends while rendering, the Suspense boundary will switch to rendering fallback. When the data is ready (the component has loaded), React will retry rendering the tree wrapped by the closest parent Suspense boundary from scratch.
-
Practice Questions: 8
flowchart TD
A[Rendering React App] -->|a child component named 'C' suspends| B[Pause rendering the subtree rooted from component C]
B --> C[Continue rendering the rest of the App]
C --> D[Render Suspense fallback]
D --> |data for component C is ready|E[Re-render components wrapped by the closest parent Suspense boundary]
-
- With React error boundaries, an error will be propagated to its closest error boundary.
-
Practice Questions: 20
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
function A() {
console.log('render A')
return null
}
function App() {
const [_state, setState] = useState(false)
console.log('render App')
return <div>
<button onClick={() => {
console.log('click')
setState(true)
}}>click me</button>
<A />
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'))
userEvent.click(screen.getByText('click me'))
userEvent.click(screen.getByText('click me'))
userEvent.click(screen.getByText('click me'))
Answer
// mounting
'render App'
'render A'
// first click
'click'
'render App'
'render A'
// second click
'click'
'render App'
// third click
'click'
This question is designed to assess your comprehension of how React optimizes re-rendering by using bailouts.
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 official document
2. React still needs to enter a component (to do some comparison) to make sure that a component should be bailed out.
Even if we don't update the state Hook to a different value on the second click, we still see "render App" printed because React needs to enter the App component to verify that no changes have been made and the App component should be bailed out. Once React has bailout the App component, its descendant components will not be re-rendered either.
On the third click, there are no actual changes to the state, and the App component is bailed out, so only "click" is printed.
import React, { useState, useRef, useEffect } from 'react';
import ReactDOM from 'react-dom'
function App() {
const [show, setShow] = useState(true)
return <div>
{show && <Child unmount={() => setShow(false)} />}
</div>;
}
function Child({ unmount }) {
const isMounted = useIsMounted()
useEffect(() => {
console.log(isMounted)
Promise.resolve(true).then(() => {
console.log(isMounted)
});
unmount();
}, []);
return null;
};
function useIsMounted() {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => isMounted.current = false;
}, []);
return isMounted.current;
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)
Answer
// mount
false
// update
false
function Child({ unmount }) {
const isMounted = useIsMounted() // mounting
useEffect(() => {
console.log(isMounted) // mounted
Promise.resolve(true).then(() => {
console.log(isMounted) // update
});
unmount(); // called when mounted, cause an update
}, []);
return null;
};
function useIsMounted() {
const isMounted = useRef(false); // mounting
useEffect(() => {
isMounted.current = true; // mounted
return () => isMounted.current = false; // cleanup function called during next update
}, []);
return isMounted.current; // mounting
}
When you create a copy of a primitive value, such as a string or a number, it is completely independent of the original value. In other words, changing the copy will not affect the original value in any way.
However, when you make a copy of reference data, such as an object or an array, it's like duplicating a key to a house. If you use this copied key to enter the house and make any changes to the interior, these modifications will be reflected for anyone else who also has access to this house.
The value returned by useIsMounted
is a snapshot of a primitive value during mounting, and any changes made to the ref after that moment will not affect this initialy returned value.
It's worth noting that refs created by useRef does not automatically trigger a re-rendering. As a result, the initial value of isMountedfalse
is called twice.
You may also be curious about why console.log(isMounted)
called with Promise
is still false. To understand this, you need a little background knowledge about the execution context of each effect in React.
You can think of each render in React as a layer of dreams in the movie "Inception", and the data that useEffect can directly access is not infinite in time and space - it can only read the execution context that corresponds to the time when a useEffect was called. This is why the Promise console.log(isMounted)
still shows the isMounted
value generated during Child component's the initial rendering.
Before answering this question, let's do another quiz:
What will be printed after runing the below code?
import React, { useState, useRef, useEffect } from "react";
import ReactDOM from 'react-dom/client';
function App() {
const [show, setShow] = useState(true);
return <div>{show && <Child setShow={setShow} />}</div>;
}
function Child({ setShow }) {
const isMounted = useIsMounted();
useEffect(() => {
console.log(isMounted.current);
setShow(() => {
console.log('update state');
return false;
});
}, []);
return null;
}
function useIsMounted() {
const isMounted = useRef(2);
useEffect(() => {
isMounted.current += 3;
return () => {
isMounted.current *= 2
console.log(isMounted.current);
};
}, []);
return isMounted;
}
const root = ReactDOM.createRoot(
document.getElementById('root')
);
root.render(< App />)
Answer
// mounted
5
"update state"
// after state updated
10
Instead of returning just the isMounted.current value, we are now returning the entire isMounted object. This means although we are holding the reference key of the ref object created during mounting, and we can refer to an updated version of the isMounted ref
in the componentDidMount lifecycle.
As a result, the logged isMounted.current
values are as expected:
- 5: After mounting, the
isMounted.current
value is 5, which is updated by theuseEffect
function in the custom hook. - 10: When
setShow
is triggered, it triggers a re-render of the App component, which causes the Child component to be unmounted and the cleanup function ofuseIsMounted
to be called. (useEffect: React will call your cleanup function each time before the Effect runs again, before the related component unmount)
As a result, isMounted.current = 10
Note: the ref returned by useIsMounted
references the same ref object during each rendering (to understand it, try to log isMounted
in the above code).
- return the ref object itself in useIsMounted
- call ref.current to access the latest value
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
function renderWithError() {
throw new Error('error');
}
function A() {
return <ErrorBoundary name="boundary-2">{renderWithError()}</ErrorBoundary>;
}
function App() {
return (
<ErrorBoundary name="boundary-1">
<A />
</ErrorBoundary>
)
}
class ErrorBoundary extends Component<
{ name: string; children: React.ReactNode },
{ hasError: boolean }
> {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch() {
console.log(this.props.name);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
ReactDOM.render(<App/>, document.getElementById('root'))
Answer
boundary-1
1. When an error is caught by an ErrorBoundary, it stops propagating downwards and calls the componentDidCatch method.
When using React error boundaries, an error will be propagated to its closest error boundary.
So when we catch an error in component A, it will be propagated to its closest error boundary, which in this case is .
2. If the error is thrown by the ErrorBoundary component itself, it won't be caught by itself but by the nearest outer component instead.
Since renderWithError
is not even a component, the error will be caught by the nearest outer boundary-2. If renderWithError
is a component, then the logged output could be "boundary-1"
What will be printed in this case?
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
function renderWithError() {
throw new Error('error');
}
function A() {
return <ErrorBoundary name="boundary-4">{renderWithError()}</ErrorBoundary>;
}
export default function App() {
return (
<ErrorBoundary name="boundary-1">
<ErrorBoundary name="boundary-2">
<A />
</ErrorBoundary>
</ErrorBoundary>
)
}
class ErrorBoundary extends Component<
{ name: string; children: React.ReactNode },
{ hasError: boolean }
> {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch() {
console.log(this.props.name);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Answer
boundary-2
This time, when we catch an error in A, it propagates to the closest error boundary outside of A, aka boundary-2.
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
function App() {
const [state1, setState1] = useState(1);
const [state2] = useState(() => {
console.log(2);
return 2;
});
console.log(state1);
useEffect(() => {
setState1(3);
}, []);
return null;
}
ReactDOM.render(<App/>, document.getElementById('root'))
Answer
// mounting
2
1
// updating
3
When we call useState
, the callback function we pass to it will only be executed during mounting, aka before the component is rendered to the DOM for the first time. This is necessary to ensure that the initial states are properly set before rendering the component.
In this specific code, both console.log(2)
and console.log(state1)
are executed during mounting. As console.log(2)
is executed before console.log(state1)
, we see "2" and "1" printed in the console.
2. Callbacks passed to useState only be called during mounting, statements placed in the body of a function component (at the top level) are executed each time the component is rendered.
When we call setState1()
, it triggers a component update, and the newly updated value of state1 (which is 3) will be printed. Also, since console.log(2)
is inside a useState
callback function, it is only called once during mounting and not printed in subsequent re-renders.
import React, { useRef, useLayoutEffect } from 'react'
import ReactDOM from 'react-dom'
function App() {
const ref = useRef(false)
useLayoutEffect(() => {
console.log(1)
ref.current = true
})
return <button
autoFocus
onFocus={() => {
console.log(!!ref.current)
}}
>
button
</button>
}
ReactDOM.render(<App/>, document.getElementById('root'))
Answer
// mount
false
1
This question is quite interesting, as it mentions a browser event that will be completed before the layout phase. Here, onFocus is triggered by autoFocus. This happens during DOM mutation, and useLayoutEffect occurs after DOM mutation. So we see the onFocus event running before useLayoutEffect.
However, generally speaking, most browser events won't run before useLayoutEffect. The useLayoutEffect hook is designed to run as soon as the component has been updated in the DOM, before the browser has had a chance to perform layout or paint.
The autoFocus case is a somewhat unique situation because it is a browser-level behavior that focuses the element as soon as it is inserted into the DOM. This behavior can lead to the onFocus event being triggered before useLayoutEffect has a chance to run.
Looking at the call stack of onFocus and useLayoutEffect, you will find the callback function of onFocus() is called before commitMount, and the callback of useLayoutEffect is called after commitMount. So we printed ref.current before setting it to true.
Changes made to ref.current do not cause React to re-render. Without re-rendering, the new value of ref.current is not printed here.
import React, { useState } from 'react'
import ReactDOM, { flushSync } from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
function App() {
const [state, setState] = useState(0)
const onClick = () => {
console.log('handler')
flushSync(() => {
setState(state => state + 1)
})
console.log('handler ' + state)
}
console.log('render ' + state)
return <div>
<button onClick={onClick}>click me</button>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'))
// click the button
userEvent.click(screen.getByText('click me'))
Answer
// mount
render 0
// click event
handler
// flushSync event, causing an immediately re-render within the current component
render 1
// rest of the click event
hanlder 0 //
FlushSync triggers an immediate re-render of the current component, and makes the setState(state => state + 1)
function run synchronously.
This also explains why we see the App component re-render before the console.log('render ' + state)
function is called.
FlushSync forces complete re-rendering, for updates that happen inside the call. But it doesn’t break the guarantee of internal consistency between props, state, and refs.
This ensures the lines of code after flushSync
still acts like what it supposed to be.
That's why even we see the state inside console.log('handler ' + state)
is still '0', not affected by results of flushSync
.
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
function App() {
const [state, setState] = useState(0)
const onClick = () => {
console.log('handler')
setState(state => state + 1)
console.log('handler ' + state)
}
console.log('render ' + state)
return <div>
<button onClick={onClick}>click me</button>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'))
// click the button
userEvent.click(screen.getByText('click me'))
Answer
// mount
render 0
// click event
handler
hanlder 0
// update
render 1
This question mainly tests your understanding about setState.
Therefore, when we call console.log('handler ' + state)
, setState(state => state + 1)
is not execuated and state is not updated yet.
This results in 'handler 0' for console.log('handler ' + state).
SetState(state + 1)
triggers state change and re-render App.
Re-rendering App causes console.log('handler ' + state)
to run again.
import React, { memo, useState } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
function _B() {
console.log('B')
return null
}
const B = memo(_B)
function _A({ children }) {
console.log('A')
return children
}
const A = memo(_A)
function App() {
const [count, setCount] = useState(0)
return <div>
<button onClick={
() => setCount(count => count + 1)
}>
click me
</button>
<A><B/></A>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'))
userEvent.click(screen.getByText('click me'))
Answer
// mounting
A
B
// update
A
The first 2 logs are from the first time rendering, which is unavoidable.
Why after the App is re-rendered, we only get A?
- When the App is re-rendered, A's props.children is about to change. Also, as memo becomes ineffective when props change, this causes A will be re-rendered.
- B has no props, so it won't be re-rendered.
This code tried to avoid re-rendering by using memo, but memo only works when its props are not changed.
flowchart TD
A[mount] --> B[onClick event fired from App: states change in App]
B --> C[re-render App and its descendants, if no bailout]
flowchart TD
A[mount: console.log 'A'] --> B[onClick event fired, App re-render: new React element objects created for A and B]
B --> C[re-render A ? A wrapped with memo, check its props]
C --> D[props of A changed? shallow compare on A's props.children, props changed]
D --> E[props of A changed: A re-render ]
flowchart TD
A[mount: console.log 'B'] --> B[onClick event fired, App re-render]
B --> C[re-render B ? B wrapped with memo, check its props]
C --> D[props of B changed? B has no props, props not changed]
D --> E[props of B not changed: B bailout]
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
function App() {
const [state, setState] = useState(0)
const increment = () => {
setTimeout(() => {
setState(state + 1)
}, 0)
}
console.log(state)
return <div>
<button onClick={increment}>click me</button>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'))
// click the button twice
userEvent.click(screen.getByText('click me'))
userEvent.click(screen.getByText('click me'))
Answer
// mount
0
// setState(state + 1)
1
// setState(state + 1)
1
This question tests your understanding about automatic batching.
Batching is when React groups multiple state updates into a single re-render for better performance.
Before version 18, React only batched updates inside React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default.
So in React 17, when our mock of 2 click events are fired, 2 setState(state + 1) calls will cause 2 re-renders.
This is because setState(state + 1) were wrapped inside setTimout (outside React event handlers) and would not be automatically batched.
As a result, we have number "1" logged twice.
When it comes to React 18, the answer of this question is changed.
Can you guess what will be logged by the previous code running in React 18?
View Answer
// mount
0
// update
1
The two batched setState() calls only cause 1 re-render,
since React 18 begins to batch setState calls regardless of setTimeout().
If you are still a little bit confused, to make it clear, you can replace the input of setState with a random number.
export default function App() {
const [state, setState] = useState(0);
useEffect(() => {
// click the button twice
userEvent.click(screen.getByText("click me"));
userEvent.click(screen.getByText("click me"));
}, []);
const increment = () => {
setTimeout(() => {
const randomNum = Math.random();
console.log('random', randomNum);
setState(randomNum);
}, 0);
};
console.log('render', state);
return (
<div>
<button id="btn" onClick={increment}>
click me
</button>
</div>
);
}
For React 18, we have:
// mount
render 0
// click events
random 0.7874529322933663
random 0.31142343139325157
// re-render 1
render 0.31142343139325157
For React 17, we have:
// mount
render 0
// click event
random 0.6529578729308463
// re-render 1
render 0.6529578729308463
// click event
random 0.636291442544388
// re-render 2
render 0.636291442544388
import React, { useRef, useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
function App() {
const ref = useRef(null)
const [state, setState] = useState(1)
useEffect(() => {
setState(2)
}, [])
console.log(ref.current?.textContent)
return <div>
<div ref={state === 1 ? ref : null}>1</div>
<div ref={state === 2 ? ref : null}>2</div>
</div>
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)
Answer
// mount
undefined
// update
1
1. If you set ref.current
to a JSX node, ref.current will not be updated until rendering is finished.
This is why when logging ref.current?.textContent
during mounting, the above code only gets undefined
. As it is still rendering, ref.current
is not updated yet.
// after mounting
<div>
<div ref={ref}>1</div>
<div ref={null}>2</div>
</div>
When we triggered setState
in useEffect
, a component will be re-rendered. However, no matter how many times your component is re-rendered, a ref always points to the ref object, created when the component was first rendered.
A ref object doesn’t trigger a re-render when you change it. For example, even though the latest ref.current?.textContent
is 2, it is not logged as changing a ref object does not call the component to re-render and log the updated value.
after 2nd time rendering
<div>
<div ref={null}>1</div>
<div ref={ref}>2</div>
</div>
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
function App() {
const [state, setState] = useState(0)
console.log(state)
useEffect(() => {
setState(state => state + 1)
}, [])
useEffect(() => {
console.log(state)
setTimeout(() => {
console.log(state)
}, 100)
}, [])
return null
}
ReactDOM.render(<App/>, document.getElementById('root'))
Answer
// mounting
0
// mounted
0
// state updated
1
// async macrotask setTimeout referencing the state from the previous rendering
0
// setTimeout capsulated with states in the initial rendering cycle. Although its callback is executed after the next rendering cycle is ready, this does not interfere with the consistency of its scope
This question tests your understanding about when useEffect fires.
const [number, updateNumber] = useState(0);
useEffect(() => {
// code
}, [number])
For the above code, everytime when number changes, the callback inside this useEffect will be called.
In this case, code inside useEffect will only be called after first rendering (mounting).
Back to our code:
console.log(state)
useEffect(() => {
setState(state => state + 1)
}, [])
useEffect(() => {
console.log(state)
setTimeout(() => {
console.log(state)
}, 100)
}, [])
As you can see, both of the 2 useEffect only have an empty array as their dependency. This means callbacks of the 2 useEffect() will be called after mounting.
flowchart TD
A[During Mounting: print '0'] --> B[After Mounting]
B --> C[1st useEffect callback: setState]
B --> D[2nd useEffect callback: print '1' and setTimeout called with reference of state '0']
C --> E[Re-render: state change to '1', print '1']
D --> F[setTimeout callback execuated in the end: print '0']
This question shows the common stale closure problem in React hooks. To understand the stale closure problem of React hooks, you need to understand what React does after triggering setState.
- SetState triggered Re-rendering will create a new layer of state snapshots. To understand state snapshot, here is a simple analogy: if a component is rendered 10 times, it is equivalent to taking 10 new photos for this changing component (instead of directly modifying the component for 10 times).
- Different layers of state snapshots are independent. This means, it is possible for a function to run with a previous state context, resulting in what we called
stale closure
.
Before continuing to explain, let me ask you a question first.
- For the state in the second useEffect, which state snapshot does it refers to?
useEffect(() => {
console.log(state)
setTimeout(() => {
console.log(state)
}, 100)
}, [])
The useEffect(() => {}, [])
hook only points to the state of the first rendering of the component. This means the execution context of setTimeout()
's callback is the mounting state snapshot. In such a case, even if we trigger a setState
to change the value of state and create a layer of state snapshot for the re-render, setTimeout(() =>; { console.log(state) }, 100)
always read the state value of the first rendering state snapshot.
import React, { memo, useState} from 'react'
import ReactDOM from 'react-dom'
import { screen, fireEvent } from '@testing-library/dom'
function _A({ onClick }) {
console.log('A')
return <button onClick={onClick} data-testid="button">click me</button>
}
const A = memo(_A)
function App() {
console.log('App')
const [state, setState] = useState(0)
return <div>
{state}
<A onClick={() => {setState(state => state + 1)}}/>
</div>
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>);
// click the button
;(async function() {
const button = await screen.findByTestId('button')
fireEvent.click(button)
})()
Answer
// mount
App
A
// update
App
A
Here memo
does not memorize component _A
, because _A
’s props are changing.
Every time App
is rendered, onClick
corresponds to a new reference value for the function, causing _A
’s props to change constantly and memo
does not take effect.
Shallow comparison refers to the process of comparing the first layer of properties of two objects to determine if they are equal. In other words, it looks at the top-level properties of both objects without considering nested properties or structures within them.
This explains why "every time App
is rendered, onClick
corresponds to a new reference value for the function".
When App is rendered, onClick is given a new reference value because the function is redefined within the component during each render cycle. This results in a new function reference being created, even if the function's contents remain the same.
Since a shallow comparison only checks the top-level properties and their references, it would consider the onClick functions in consecutive renders as different, even though their actual behavior is unchanged.
function _A({ onClick }) {
console.log('A');
return <button onClick={onClick} data-testid="button">click me</button>;
}
const A = memo(_A);
export default function App() {
console.log('App');
const [state, setState] = useState(0);
const handleClick = useCallback(() => {
setState((state) => state + 1);
}, []);
return (
<div>
{state}
<A onClick={handleClick} />
</div>
);
}
The useCallback
function is used to wrap the handleClick
callback function, so that the reference of the onClick
callback remains unchanged.
import React, { useState, createContext, useEffect, useContext} from 'react'
import ReactDOM from 'react-dom'
const MyContext = createContext(0);
function B({children}) {
const count = useContext(MyContext)
console.log('B')
return children
}
const A = ({children}) => {
const [state, setState] = useState(0)
console.log('A')
useEffect(() => {
setState(state => state + 1)
}, [])
return <MyContext.Provider value={state}>
{children}
</MyContext.Provider>
}
function C() {
console.log('C')
return null
}
function D() {
console.log('D')
return null
}
function App() {
console.log('App')
return <A><B><C/></B><D/></A>
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)
Answer
// mounting
App
A
B
C
D
// state updated
A
B
1. When triggering setState in a component, it can cause this component and its child components to be re-rendered.
From the above code, we can see that when we trigger setState in component A, component A will be re-rendered, which does not affect the parent component App of A, nor does it affect sibling component D.
Since A's children components are inherited from its parent component through props.children
, A's re-rendering will not cause the inherited children components to be re-rendered.
For example, components C and D are passed to A's through App as props.children. When A is re-rendered, its props have not changed, so components C and D are not rendered.
But why does B component re-render, affected by state changes in A?
In fact, A changes its state and modifies MyContext as well. Modifying MyContext in A will cause all components that share the same context to re-render.
This is why even if B component is passed to A as props.children, its will re-renders as it shares the same context with A, "MyContext".
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
function A({ children }) {
console.log('A')
return children
}
function B() {
console.log('B')
return <C/>
}
function C() {
console.log('C')
return null
}
function D() {
console.log('D')
return null
}
function App() {
const [state, setState] = useState(0)
useEffect(() => {
setState(state => state + 1)
}, [])
console.log('App')
return (
<div>
<A><B/></A>
<D/>
</div>
)
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)
Answer
// mounting
App
A
B
C
D
// state updated
App
A
B
C
D
B and C were re-rendered because A's parent component re-rendered, resulting in a new prop.children for A (the reference value of props.children is changed).
Note: React uses shallow comparison to detect whether props are changed or not. So even if we have not see real changes made to props of A, React still considers that it has props changes.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import React, { useState, useRef, useEffect, Suspense } from "react";
import ReactDOM from "react-dom/client";
const resource = (() => {
let data = null;
let status = "pending";
let fetcher = null;
return {
get() {
if (status === "ready") {
return data;
}
if (status === "pending") {
fetcher = new Promise((resolve, reject) => {
setTimeout(() => {
data = 1;
status = "ready";
resolve();
}, 2000);
});
status = "fetching";
}
throw fetcher;
}
};
})();
function A() {
console.log("A1");
return (
<>
<C />
<D />
</>
);
}
function D() {
console.log("D");
return null;
}
function C() {
resource.get();
console.log("C");
return <E />;
}
function E() {
console.log("E");
return <div>E</div>;
}
function B() {
useEffect(() => {
resource.get();
}, []);
console.log("B");
return <div>B</div>;
}
function Fallback() {
console.log("fallback");
return <p>fallback</p>;
}
function App() {
console.log("App");
return (
<div>
<Suspense fallback={<Fallback />}>
<A />
<B />
</Suspense>
</div>
);
}
const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(<App />);
Answer
App
A1
D
B
fallback
A1
C
E
D
B
- Why didn’t component C and its child components render during first time rendering?
Because component C made a data request that can be recognized by Suspense, React will not attempt to continue rendering the part of the subtree that made the data request until it completes the data request, so C and E are not rendered and logged but fallback is.
2. In Suspense, after Suspense-enabled data requests are completed, the subtree wrapped by Suspense will be re-rendered
That's why we see the App is re-rendered from the children of Suspense. (App is outside Suspense and not re-rendered)
Data requests initiated in useEffect will not be detected by Suspense, which is why component B was rendered and not suspended during initial rendering.
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
function App() {
const [state, setState] = useState(0)
console.log("App " + state)
return (
<div>
<button onClick={() => {
setState(count => count + 1)
setState(count => count * 2)
}}>click me</button>
</div>
)
}
(async () => {
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)
userEvent.click(await screen.findByText('click me'))
})()
Answer
App 0
App 2
- App 0 This log is triggered when the App component is first rendered.
- App 2 This log is triggered after React merges two setState calls into one re-render.
React batches setState calls to avoid unnecessary re-rendering.
2. Background knowledge: setState (as well as useState hook setter) is a trigger of a series of underground React process
Before we explain why batching exists, let's introduce some background knowledge about why setState triggers a re-render. If you're new to React, you might think that using setState to modify state values simply means modifying data. However, when you call setState (or useState's setter function), this triggers a series of underlying operations in React, including reconciling changes to the component tree, marking changes, and committing rendering. Therefore, setState is not just a data modification function; it's more like a trigger button for a series of underlying operations in React.
So, without batching, the above code would trigger two rounds of underlying React operations unnecessarily. If you look closely at this code, you'll find that there's no need to perform two rounds of re-rendering; it's actually equivalent to this single rendering:
setState(count => (count + 1) * 2)
Therefore, in order to avoid unnecessary re-rendering like the above example, React uses batching to merge the two setState operations in the original code into one, so that only one re-render is triggered in the end.