You can purchase the book at Advanced React. I highly recommend it to anyone interested in React performance. The book goes deep into how React works yet remains very approachable.
Diffing, Reconciliation and Children as Props
With the following JSX
const Component = () => { return ( <div> <Input placeholder="Text1" id="1" /> <Input placeholder="Text2" id="2" /> </div> );};The underlying representation is
{ type: 'div', props: { children: [ { type: Input, props: { id: "1", placeholder: "Text1" } }, { type: Input, props: { id: "2", placeholder: "Text2" } } ] }}When this component re-renders, React compares the objects from “before” and “after” the state update. If an entry is the same (by reference), it won’t re-render. If an entry changes, there are two cases:
-
If the
typeis the same and the props change, theInputcomponent will be marked as “needs update,” and its re-render will be triggered. -
If the
typehas changed, React will remove (unmount) the “previous” component and add (mount) the “next” component.
Let’s say we have a different component that does conditional rendering
const Component = () => { if (isCompany) return <Input />; return <TextPlaceholder />;};Then, if the update was triggered by isCompany flipping from true to
false, React compares these objects:
// Before update, isCompany was "true"{ type: Input, ...}
// After update, isCompany is "false"{ type: TextPlaceholder, ...}type has changed from Input to TextPlaceholder, so React will
unmount Input and remove everything associated with it from the DOM.
Then it will mount the new TextPlaceholder component and append it to
the DOM for the first time.
This diffing mechanism explains several things:
- When the parent re-renders, a child component also re-renders (assuming no memoization), even if it does not use the state. Take the following component:
const Parent = (props) => { const [state, setState] = useState(); return <Child />;};While Child does not touch the state, <Child /> is just syntax sugar
for creating the object
{ type: Child, props: [] }and objects are compared by reference, so the two objects before and
after render are always different and Child re-renders.
- However, if an entry is created outside the rendering cycle of
Parent, re-renderingParentdoes not recreate the entry’s JSX object, and React will not re-render that entry. This is often the case with children props:
const Parent = ({ child }) => { const [state, setState] = useState(); return child;};
<Parent child={<Child />} />;Now the JSX object for Child is created outside the parent. Parent
simply passes down the object reference, so it remains the same inside
the Parent rendering cycle.
Similarly, render props and children props won’t re-render if they
don’t rely on the parent state. Remember, nesting children is just
syntax sugar for an explicit children prop.
<Parent> <Child /></Parent>
// the same as:<Parent children={<Child />} />- Nesting component definitions is harmful because functions are also
compared by reference. If you define a child component inside the
parent, it is seen as a different
typeon every render and goes through unmount-remount every time.
const Component = () => { const Input = () => <input />; return <Input />;};The component returns
{ type: Input, // a different function reference every render, so Input is a different component that needs to be remounted everytime}Force Un-mounting
As mentioned before, React uses type to determine whether a component
at a particular position in the tree can be reused.
Consider the following example
const Form = () => {const [isCompany, setIsCompany] = useState(false); return ( <> {/*checkbox somewhere here*/} {isCompany ? ( <Input id="company-tax-id-number" /> ) : ( <Input id="person-tax-id-number" /> )} </> )}If we toggle isCompany from false to true, React will compare the
following trees:
Before
[ { type: Checkbox, }, { type: Input, props: { id: "company-tax-id-number" } },];After
[ { type: Checkbox, }, { type: Input, props: { id: "person-tax-id-number" } },];Checkbox re-renders as usual. The critical piece is that Input is
considered a component that only needs a prop update, because type
refers to the function component. As a result, if the input already
holds HTML state, such as an existing value, it persists after we toggle
the state, because React only updates the prop without unmounting. This
is not necessarily bad, but it’s something to consider when designing
the UX.
If we do want to remount Input after toggling, we can change the JSX
structure:
const Form = () => { const [isCompany, setIsCompany] = useState(false); return ( <> <Checkbox onChange={() => setIsCompany(!isCompany)} /> {isCompany ? <Input id="company-tax-id-number" /> : null} {!isCompany ? <Input id="person-tax-id-number" /> : null} </> )}Before, isCompany is false:
[{ type: Checkbox }, null, { type: Input }]After, isCompany is true:
[{ type: Checkbox }, { type: Input }, null];Here, React mounts Input for the second item and unmounts Input for
the third item.
This technique also works in reverse. With the same JSX structure, if we do want to keep the state and reuse the same input, we can give both the same key:
{isCompany ? <Input id="company-tax-id-number" key="text-input" /> : null }{!isCompany ? <Input id="person-tax-id-number" key="text-input" /> : null }Before
[{ type: Input, key: "text-input" }, null]After
[null, { type: Input, key: "text-input" }]React sees an array of children where, before and after the re-render,
there is an element with the Input type and the same “key.” So it
assumes the Input component just changed its position in the array and
reuses the already created instance.
Keys
Another way to force remounting is the key attribute.
The root of our problem is that React uses type to distinguish
components. If an element has a key attribute, React uses it as an
additional identifier: an element is considered the same if it has the
same type and key before and after render. To solve our problem
using key:
{isCompany ? <Input id="company-tax-id-number" key="company" /> : <Input id="person-tax-id-number" key="person" />}Before
{ type: Input, key: "company" }After
{ type: Input, key: "person" }They are now considered different components.
The Problem of index-based key
When rendering a static list, it’s usually fine to use the array index
as key. But if list items can be reordered, index-based keys are
problematic. Consider the following example:
const data = ['1', '2'];
const Component = () => { return data.map((value) => <Input key={value} />);};The tree is
[ { type: Input, key: 0 }, // "1" data item { type: Input, key: 1 }, // "2" data item];If you reorder the two Input, the tree becomes
[ { type: Input, key: 0 }, // "2" data item { type: Input, key: 1 }, // "1" data item];Since the keys are index-based, they don’t change after the reorder.
While we know the two items are swapped, React sees the same type and
key and reuses the state. So if you type something in the first input
and then swap, the text stays in the first input (by position).
This won’t happen if we use a real id that uniquely identifies the input.
Before
[ { type: Input, key: "real id 1" }, // "1" data item { type: Input, key: "real id 2" }, // "2" data item];After
[ { type: Input, key: "real id 2" }, // "2" data item { type: Input, key: "real id 1" }, // "1" data item];The keys at both positions have changed, so React moves the existing
state to the correct Input and swaps the two DOM nodes.
Split providers to improve context performance
A typical setup with React context is
const NavigationController = ({ children }) => { const [isNavExpanded, setIsNavExpanded] = useState(); const toggle = () => setIsNavExpanded(!isNavExpanded); const value = { isNavExpanded, toggle }; return ( <Context.Provider value={value}> {children} </Context.Provider> );};const useNavigation = () => useContext(Context);The problem is that whenever the state isNavExpanded updates, the
value object is recreated, triggering a re-render of downstream
components, even those that only need the setIsNavExpanded action.
Another problem is that if the parent of NavigationController
re-renders often, any child of NavigationController also has to
re-render, e.g.:
const Layout = ({ children }) => { const [scroll, setScroll] = useState();
useEffect(() => { window.addEventListener('scroll', () => { setScroll(window.scrollY); }); }, []);
return ( <NavigationController> <div className="layout">{children}</div> </NavigationController> )};Layout re-renders on every scroll, which re-renders
NavigationController and recreates the value object, so every
subscriber re-renders.
Memoization can solve the second problem but not the first. Consider the following changes:
const toggle = useCallback(() => { setIsNavExpanded(val => !val);}, []);
const value = useMemo(() => { return { isNavExpanded, toggle };}, [isNavExpanded, toggle]);
return ( <Context.Provider value={value}> {children} </Context.Provider>)With a memoized value, context subscribers won’t re-render when
Layout updates. But the state isNavExpanded and the action toggle
are still coupled: a component that only needs the action still
re-renders when the state changes.
Solution: two providers
type State = { isNavExpanded: boolean };const defaultState: State = { isNavExpanded: false };// store the state hereconst ContextData = React.createContext(defaultState);// store the open/close functions hereconst ContextApi = React.createContext({ open: () => {}, close: () => {}, toggle: () => {}});
type Action = { type: 'open-sidebar' | 'close-sidebar' | 'toggle-sidebar' };const reducer = (state: State, action: Action): State => { switch (action.type) { case 'open-sidebar': return { ...state, isNavExpanded: true }; case 'close-sidebar': return { ...state, isNavExpanded: false }; case 'toggle-sidebar': // we'll have access to the old value here - it's our "state" // so just flip it around return { ...state, isNavExpanded: !state.isNavExpanded }; }
return state;};
const NavigationController = ({ children }: { children: ReactNode }) => { const [state, dispatch] = useReducer(reducer, defaultState);
// that one has a dependency on state const data = useMemo(() => ({ isNavExpanded: state.isNavExpanded }), [state]);
// that one never changes - no dependencies const api = useMemo(() => { return { open: () => dispatch({ type: 'open-sidebar' }), close: () => dispatch({ type: 'close-sidebar' }), toggle: () => dispatch({ type: 'toggle-sidebar' }), }; // don't depend on the state directly anymore! }, []);
return ( <ContextData.Provider value={data}> <ContextApi.Provider value={api}>{children}</ContextApi.Provider> </ContextData.Provider> );};
const useNavigationData = () => useContext(ContextData);const useNavigationApi = () => useContext(ContextApi);This way, the actions don’t depend on the state, and the two
useContext calls are independent.
An Alternative to Splitting Providers with HOC
const withNavigationOpen = (AnyComponent) => { // wrap the component from the arguments in React.memo here const AnyComponentMemo = React.memo(AnyComponent); return (props) => { const { open } = useContext(Context); // return memoized component here // now it won't re-render because of Context changes // make sure that whatever is passed as props here don't return <AnyComponentMemo {...props} openNav={open} />; }};AnyComponentMemo is wrapped in React.memo and the open callback is
memoized in the context itself, so AnyComponentMemo won’t re-render if
only the context state changes.
Maintaining Stateful Closures with Ref
When passing callbacks to child components, the callback often needs
access to some state, so the state must go in the useCallback
dependency array. Now the callback is recreated every time the state
changes, which re-renders the consumer component and breaks its
memoization.
const [state, setState] = useState();const onclick = useCallback(() => { console.log(state)}, [state]);
return <ChildMemo onClick={onclick} />;Solution: create the callback only once, and inside it refer to a ref
that is updated with the latest state.
const [value, setValue] = useState();const ref = useRef();
const onClick = useCallback(() => {this callback is created only once ref.current?.();}, [])
useEffect(() => { ref.current = () => {ref.current is updated with the latest state console.log(value); };}, [value]);
return <ChildMemo onClick={onClick} />Implement debouncing
There are two ways to debounce in React:
- Debounce the state, and only call the function when the debounced state updates. This is the simpler approach.
const useDebounce = (value, delay) => { const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay);
return () => { clearTimeout(handler); }; }, [value, delay]);
return debouncedValue;};
// component.tsxconst [value, setValue] = useState();const debouncedValue = useDebounce(value, 500);
useEffect(() => { callback(debouncedValue);}, [debouncedValue]);- Debounce the function (what the book teaches). With this approach, we
can use
debouncefrom lodash (or a similar utility library). The gist is that we create the debounced function only once and use the ref trick to make sure it always has the latest state.
import debounce from "lodash.debounce";
function Input() { const [value, setValue] = useState(); const ref = useRef();
const sendRequest = () => { console.log(value) }
const debouncedRequest = useMemo(() => {create the debounced function once const func = () => { ref.current?.() }
return debounce(func, 500); }, [])
useEffect(() => { ref.current = sendRequest;update the ref with the latest state }, [value]);
const onChange = (e) => { const value = e.target.value; setValue(value);
debouncedRequest(value) }
return <input onChange={onChange} value={value} />;}useLayoutEffect
On a 60 FPS machine, the browser repaints the screen approximately every 16ms. If a task pulled from the task queue takes longer than 16ms, the browser waits until it finishes before repainting. In this code
const Component = () => { useLayoutEffect(() => { // do something }) return ...}useLayoutEffect and the return statement are considered one task.
In other words, useLayoutEffect runs synchronously, so every UI update
is in sync with its side effect. In contrast, useEffect runs
asynchronously, after repainting.
As a result, any side effect in useLayoutEffect is processed before
the browser repaints the screen, at the cost of a slower UI update. If
the task takes longer than 16ms, the UI becomes unresponsive.
See the React official
example for
positioning a tooltip element with useLayoutEffect.
To do this, you need to render in two passes:
-
Render the tooltip anywhere (even with a wrong position).
-
Measure its height and decide where to place the tooltip.
-
Render the tooltip again in the correct place.
Even though the first render is incorrect, the user won’t see it,
because the browser only repaints after the useLayoutEffect task
finishes, and by then the positioning is done.
function Tooltip() { const ref = useRef(null); const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet
useLayoutEffect(() => { const { height } = ref.current.getBoundingClientRect(); setTooltipHeight(height); // Re-render now that you know the real height }, []);
// ...use tooltipHeight in the rendering logic below...}React Portal
Events from portals propagate according to the React tree rather than
the DOM tree. For example, if you click inside a portal, and the portal
is wrapped in <div onClick>, that onClick handler will fire. If this
causes issues, either stop the event propagation from inside the portal,
or move the portal itself up in the React tree.
// clicking the button will trigger the onClick handler// because this is a React event that follows the react tree<div onClick={onClick}> {createPortal(<button />, document.body)}</div>In contrast, standard (non-React) DOM events propagate according to the
DOM tree. If you listen for the event via el.addEventListener, it
won’t fire if the event is triggered by a portal element.
const ref = useRef()
useEffect(() => {el.addEventListener("click", () => { // trying to catch events, originated in the portalled elements // not going to work!!});}, [])
return <div ref={ref} />;A gotcha is that onSubmit on a form element is a standard DOM event,
so if you have a portal inside a form, the submit event won’t be
triggered by the portal element.
Data Fetching in useEffect with Race Conditions
import { useState, useEffect } from 'react';import { fetchBio } from './api.js';
export default function Page() { const [person, setPerson] = useState('Alice'); const [bio, setBio] = useState(null);
useEffect(() => { let ignore = false; setBio(null); fetchBio(person).then(result => { if (!ignore) { setBio(result); } }); return () => { ignore = true; }; }, [person]);
// ...}The gist is that the result callback in fetchBio captures the “stale”
variable ignore, which is a reference that points to true if the
component is unmounted. So although we create a new ignore variable
for each render, the callback still cancels correctly because it
accesses the previous scope.