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 type is the same and the props change, the Input component will be marked as “needs update,” and its re-render will be triggered.

  • If the type has 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-rendering Parent does 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 type on 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 here
const ContextData = React.createContext(defaultState);
// store the open/close functions here
const 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} />;
Child re-renders on every state update

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.
use-debounce.ts
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
// component.tsx
const [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 debounce from 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.