Large components often end up re-rendering more stuff than is necessary when something changes in the component. If a component uses a lot of:
- props
- state
- context usage
- HOCs
- hooks
a change to any of these things will result in the entire component re-rendering.
Splitting up a large component into a parent component and several smaller child components means that there will be fewer things (state, context usage, etc.) in the parent component that can trigger a re-render. It also means that those things will be spread out across multiple children so when one of them changes only one of the children will re-render.
Additionally, we can memoize some or all of the new child components if we want to prevent them from re-rendering in response to props changes.
Consider LargeComponent
below. Clicking on either of the buttons will cause
the whole component to re-render.
import {useState} from "react";
import {expensiveComputation} from "./expensive-computation";
const LargeComponent = () => {
const [foo, setFoo] = useState<number>(0);
const [bar, setBar] = useState<number>(0);
const renderFoo = () => {
expensiveComputation();
return <>
<h1>foo = {foo}</h1>
<button onClick={incrementFoo}>increment foo</button>
</>;
};
const renderBar = () => {
expensiveComputation();
return <>
<h1>bar = {bar}</h1>
<button onClick={incrementBar}>increment bar</button>
</>;
};
return <>
<h1>LargeComponent</h1>
{renderFoo()}
{renderBar()}
</>
}
Extracting components Foo
and Bar
allows those components to update and
re-render indepedently of each other.
// foo.tsx
import {useState} from "react";
import {expensiveComputation} from "./expensive-computation";
export default function Foo() {
const [foo, setFoo] = useState<number>(0);
expensiveComputation();
return = <>
<h1>foo = {foo}</h1>
<button onClick={incrementFoo}>increment foo</button>
</>;
}
// bar.tsx
import {useState} from "react";
import {expensiveComputation} from "./expensive-computation";
export default function Bar() {
const [bar, setBar] = useState<number>(0);
expensiveComputation();
return = <>
<h1>bar = {bar}</h1>
<button onClick={incrementBar}>increment bar</button>
</>;
}
// parent.tsx
import Foo from "./foo";
import Bar from "./bar";
const ParentComponent = () => {
return <>
<h1>ParentComponent</h1>
<Foo />
<Bar />
</Foo>
}
- If there are still other things left in the
ParentComponent
that could trigger a re-render, you may want to memoize some or all of the children components. - Splitting up components has the added benefit of making the components easier to test. You can even mock the child components when testing the parent component.
- Use the profiler in React dev tools to measure the render performance of the code in the "exercise" folder.
- Split
LargeComponent
in the exercise/ folder into aParent
component and multiple child components. - Memoize components as necessary to address remaining render performance issues.
- Use the profiler in React dev tools to measure the render performance again.
The following pattern can be quite useful when writing jest tests for large components
that render a number of subcomponents. The subcomponents can be mocked with module
mocks to return simple strings that are easy to find using @testing-library/react
's
screen.findByText()
.
import {render, screen} from "@testing-library/react";
import {Parent} from "./parent";
jest.mock("./foo", ({
__esModule: true,
default: () => "Foo",
});
describe("Parent", () => {
it("should render 'Foo'", async() => {
// Arrange
// Act
render(<Parent />);
// Assert
const foo = await screen.findByText("Foo");
expect(foo).toBeInTheDocument();
});
});
If you want to verify that certain props were passed to a component you can use this pattern:
import {render, screen} from "@testing-library/react";
import {Parent} from "./parent";
import * as Bar from "./bar";
describe("Parent", () => {
it("should render the child component", () => {
// Arrange
const barSpy = jest.spyOn(Bar, "default").mockReturnValue(<div />);
// Act
render(<Parent />);
// Assert
expect(barSpy).toHaveBeenCalledWith({msg: "hello, world!"}, {});
});
});
NOTE: The extra {}
in the call to toHaveBeenCalledWith
is necessary.