Skip to content
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Currently, this repo is in Prerelease. When it is released, this project will ad

## Prerelease

### Adds

- Adds lazy loading to the `MultiSelect` component when `listOverflow="scroll"`.

## 4.4.0 (July 8, 2026)

### Adds
Expand Down
10 changes: 10 additions & 0 deletions src/components/MultiSelect/MultiSelect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { changelogData } from "./multiSelectChangelogData";
- [Width](#width)
- [Default Open State](#default-open-state)
- [Close on Blur State](#close-on-blur-state)
- [Lazy Loading Items](#lazy-loading-items)
- [MultiSelect in a Group](#multiselect-in-a-group)
- [Controlling state using selectedItems and onChange props](#controlling-state-using-selecteditems-and-onchange-props)
- [MultiSelect NextJS routing implementation](#multiselect-nextjs-routing-implementation)
Expand Down Expand Up @@ -751,6 +752,15 @@ focus leaves the component (the user clicks outside or uses the keyboard to tab
language="tsx"
/>

## Lazy loading items

If the items list is large and `listOverflow="scroll"`, items will be lazily
loaded to optimize performance. The component renders the first
`defaultItemsVisible + 20` items on load. Scrolling down triggers the rendering
of more items.

<Canvas of={MultiSelectStories.lazyLoadingItems} />

## MultiSelect in a Group

When using the `MultiSelect` component in a group, it is recommended to use the
Expand Down
14 changes: 14 additions & 0 deletions src/components/MultiSelect/MultiSelect.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import MultiSelect, {
import Text from "../Text/Text";
import useMultiSelect from "../../hooks/useMultiSelect";
import Button from "../Button/Button";
import withLazyLoadItems from "./MultiSelectWithLazyLoadItems";

const withItems = [
{
Expand Down Expand Up @@ -617,6 +618,19 @@ export const closeOnBlurState: Story = {
),
};

export const lazyLoadingItems: Story = {
render: () => (
<MultiSelectStory
id="multi-select-id-18"
isBlockElement
isDefaultOpen={false}
isSearchable={true}
items={withLazyLoadItems}
listOverflow="scroll"
/>
),
};

export const InAGroup: Story = {
render: () => <MultiSelectGroupStory items={withItems} />,
};
Expand Down
80 changes: 79 additions & 1 deletion src/components/MultiSelect/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,58 @@
import { axe } from "jest-axe";
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import renderer from "react-test-renderer";
import userEvent from "@testing-library/user-event";
import { useEffect } from "react";
import MultiSelect from "./MultiSelect";
import useMultiSelect from "../../hooks/useMultiSelect";
import withLazyLoadItems from "./MultiSelectWithLazyLoadItems";

jest.mock("../../hooks/useSafeId", () => ({
...jest.requireActual("../../hooks/useSafeId"),
useSafeId: jest.fn((id) => id || "test-id"),
}));

let intersectionCallback: IntersectionObserverCallback | null = null;

const observeMock = jest.fn();
const disconnectMock = jest.fn();
const unobserveMock = jest.fn();

beforeEach(() => {
intersectionCallback = null;
observeMock.mockClear();
disconnectMock.mockClear();
unobserveMock.mockClear();

window.IntersectionObserver = jest.fn().mockImplementation((callback) => {
intersectionCallback = callback;
return {
observe: observeMock,
disconnect: disconnectMock,
unobserve: unobserveMock,
};
});
});

const triggerIntersection = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice update. How did you catch that the mock scroll behavior was not actually working as expected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was failing because no more items were being loaded on scroll - seems like the scroll handler was taking over. I also didn't realize earlier that the IntersectionObserver doesn't work in the test environment because the environment doesn't have the browser layout engine.

if (!intersectionCallback) return;

intersectionCallback(
[
{
isIntersecting: true,
target: document.createElement("div"),
intersectionRatio: 1,
time: 0,
boundingClientRect: {} as DOMRectReadOnly,
intersectionRect: {} as DOMRectReadOnly,
rootBounds: null,
},
],
{} as IntersectionObserver
);
};

const items = [
{ id: "dogs", name: "Dogs", isDisabled: false },
{ id: "cats", name: "Cats", isDisabled: false },
Expand Down Expand Up @@ -470,6 +512,42 @@ describe("MultiSelect", () => {
);
});

it("should lazily load more list items with a large items list", async () => {
render(
<MultiSelect
id="multiselect-lazy-load-id"
buttonText="Multiselect button text"
items={withLazyLoadItems}
isDefaultOpen={true}
isSearchable={false}
isBlockElement={false}
listOverflow="scroll"
selectedItems={selectedTestItems}
onChange={() => null}
onClear={() => null}
/>
);

expect(screen.queryAllByRole("checkbox")).toHaveLength(25);

await waitFor(() => expect(observeMock).toHaveBeenCalled());

act(() => triggerIntersection());
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(45)
);

act(() => triggerIntersection());
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(65)
);

act(() => triggerIntersection());
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(85)
);
});

it("should call onChange when an item without child items or a child item is selected/unselected", () => {
const onChangeMock = jest.fn();
const onMixedStateChangeMock = jest.fn();
Expand Down
78 changes: 75 additions & 3 deletions src/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import {
ChakraComponent,
useMultiStyleConfig,
} from "@chakra-ui/react";
import React, { forwardRef, useEffect, useRef, useState } from "react";
import React, {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from "react";

import Accordion from "./../Accordion/Accordion";
import Button from "./../Button/Button";
Expand Down Expand Up @@ -111,6 +117,13 @@ export const MultiSelect: ChakraComponent<
const expandToggleButtonRef: React.RefObject<HTMLButtonElement> =
useRef<HTMLButtonElement>();

// Used for Intersection Observer for lazy loading
const itemsListRef: React.RefObject<HTMLDivElement> =
useRef<HTMLDivElement>();
// Observation target for Intersection Observer
const lazyLoadTargetRef: React.RefObject<HTMLDivElement> =
useRef<HTMLDivElement>();

// Tells `Accordion` to close if open when user clicks outside of the container
const handleClickOutside = (e) => {
if (e.type === "mousedown") {
Expand Down Expand Up @@ -162,12 +175,23 @@ export const MultiSelect: ChakraComponent<

const isOverflowExpand =
items.length > defaultItemsVisible && listOverflow === "expand";
const lazyLoadIncrementNum = 20;
const isOverflowLazy =
items.length > defaultItemsVisible + lazyLoadIncrementNum &&
listOverflow === "scroll";
const defaultItemsList = React.useMemo(
() => (isOverflowExpand ? items.slice(0, defaultItemsVisible) : items),
[isOverflowExpand, items, defaultItemsVisible]
);
const [itemsList, setItemsList] = useState(defaultItemsList);
const [isExpandable, setIsExpandable] = useState(true);
const [lazyItemsVisible, setLazyItemsVisible] = useState(
defaultItemsVisible + lazyLoadIncrementNum
);

const visibleItemsList = isOverflowLazy
? itemsList.slice(0, lazyItemsVisible)
: itemsList;

const selectedItemsCount: number =
selectedItems[mainId]?.items.length || 0;
Expand Down Expand Up @@ -245,6 +269,19 @@ export const MultiSelect: ChakraComponent<
return <Box>No options found</Box>;
};

const loadMoreLazyItems = useCallback(() => {
if (!isOverflowLazy) {
return;
}

setLazyItemsVisible((previousVisibleItems) =>
Math.min(
previousVisibleItems + lazyLoadIncrementNum,
itemsList.length
)
);
}, [isOverflowLazy, itemsList]);

const onChangeSearch = (event) => {
const value = event.target.value.trim().toLowerCase();
if (!value) {
Expand Down Expand Up @@ -291,6 +328,33 @@ export const MultiSelect: ChakraComponent<
setItemsList(isExpandable ? defaultItemsList : items);
}, [isExpandable, defaultItemsList, items]);

React.useEffect(() => {
if (
!isOverflowLazy ||
!itemsListRef.current ||
!lazyLoadTargetRef.current
) {
return;
}

const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadMoreLazyItems();
}
},
{
root: itemsListRef.current,
threshold: 0,
rootMargin: "0px 0px 300px 0px",
}
);

observer.observe(lazyLoadTargetRef.current);

return () => observer.disconnect();
}, [isOverflowLazy, loadMoreLazyItems, lazyItemsVisible]);

const ExpandToggleButton = (): JSX.Element => {
return (
<Button
Expand Down Expand Up @@ -411,13 +475,15 @@ export const MultiSelect: ChakraComponent<
) : null}

<Box
data-testid={isOverflowLazy ? `${mainId}-items-list` : undefined}
ref={itemsListRef}
maxHeight={listHeight}
overflowY="auto"
paddingTop="xxs"
paddingLeft="xs"
paddingBottom="xxs"
>
{itemsList.length === 0 ? (
{visibleItemsList.length === 0 ? (
<NoSearchResults />
) : (
<>
Expand All @@ -430,13 +496,19 @@ export const MultiSelect: ChakraComponent<
showLabel={false}
name="multi-select-checkbox-group"
>
{itemsList.map((item: MultiSelectItem) => (
{visibleItemsList.map((item: MultiSelectItem) => (
<React.Fragment key={item.id}>
{getMultiSelectCheckboxItem(item)}
</React.Fragment>
))}
</CheckboxGroup>
{isOverflowExpand && <ExpandToggleButton />}
{/* Target element for IntersectionObserver; intersections
trigger lazy loading callback */}
{isOverflowLazy &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense why we need this, but can you add a short comment describing why? When we revisit this component in the future, it'll be clear why this is needed in the DOM.

visibleItemsList.length < itemsList.length && (
<Box ref={lazyLoadTargetRef} height="1px" />
)}
</>
)}
</Box>
Expand Down
Loading
Loading