MUIのAutocompleteでoptionsを無限スクロールする
始めに
MUIのAutocompleteで以下のようなoptionsを追加読み込みしたい際に、公式のドキュメントを見ても開いた時に読み込むだったり、テキスト入力時にfetchするというもので、一番下までスクロールしたら追加で読み込むような無限スクロール的なやり方は載っていませんでした。
そこでGif画像のような挙動を作ってみて、それがある程度形になったので記事にまとめました。
データ取得はキャッシュされていた方が良いと思ったため、今回はreact-queryも絡めたもので説明します。
検証コード
先に今回紹介する実装のコードをCodeSandboxとして共有します。実装方法については次のセクションから説明していきますが、詳細のコードはこちらの方をご参照ください。
MUIのAutocompleteで無限スクロールする方法
追加読み込みのリクエストを発火できるようにする
まずは一番下までスクロールしたらローディングアイコンの表示と追加読み込みをするリクエストを発火できるようにします。
ListboxComponentの末尾にローディングアイコンを出せるようにカスタマイズする
MUIのAutocompleteではListboxComponent
というpropsがあり、ここでoptionリストに追加要素を配置することができます。
optionリストのコンポーネントをカスタマイズするのでoption部分がどうなるか気になりましたが、childrenとして受け取っており、特に変更しない場合はそのまま出力するだけで問題ありませんでした。
構成要素を図示したものと、コードのイメージは以下のようになります。今回は末尾のスクロール判定をreact-intersection-observerを使って表現しようと思っているため、InView
コンポーネントでラップしてローディングを表示しています。
import { Box, CircularProgress } from "@mui/material";
import { InView } from "react-intersection-observer";
import { forwardRef, HTMLAttributes } from "react";
export const LazyListboxComponent = forwardRef<
HTMLUListElement,
HTMLAttributes<HTMLElement>
>(({ children, ...restProps }, ref) => {
// 次のページがあるか。一旦定数で設定する
const hasNextPage = true;
return (
<ul ref={ref} {...restProps}>
{children}
<InView
onChange={(inView, entry) => {
if (!inView) {
return;
}
// 条件を満たした時に追加読み込みコールバックを発火させる
}}
>
{hasNextPage && (
<Box component="li" sx={{ pt: 1, textAlign: "center" }}>
<CircularProgress size={25} />
</Box>
)}
</InView>
</ul>
);
});
これをAutocompleteを呼ぶときにpropsに渡せばOKです。
<Autocomplete
ListboxComponent={LazyListboxComponent}
/>
LazyListboxComponentに情報を渡すProviderを用意する
これであとはhasNextPageなどの情報をLazyListboxComponentに渡せれば良いのですが、一つ問題があります。Autocompleteにpropsで渡す際にはコンポーネントのまま渡す必要があり、propsを渡す余地がありません。なので仕方ありませんがContext経由で情報の受け渡しをしたいと思います。
import { FC, PropsWithChildren } from "react";
import { createContext, useContext } from "react";
export type LazyLoadingContextValue = {
/** 次のページを読み込み中か */
isFetchingNextPage: boolean;
/** 次のページを読み込めるか */
hasNextPage?: boolean;
/** 次のページを読み込みたいか */
onRequestNextPage: () => void;
};
const LazyLoadingContext = createContext<LazyLoadingContextValue | undefined>(
undefined
);
export type LazyLoadingProviderProps = PropsWithChildren<{
value: LazyLoadingContextValue;
}>;
export const LazyLoadingProvider: FC<LazyLoadingProviderProps> = ({
value,
children
}) => {
return (
<LazyLoadingContext.Provider value={value}>
{children}
</LazyLoadingContext.Provider>
);
};
export const useLazyLoadingContextValue = () => {
const contextValue = useContext(LazyLoadingContext);
if (contextValue == null) {
throw new Error("No LazyLoadingProvider set, use LazyLoadingProvider");
}
return contextValue;
};
これで以下のような感じで受け取れるようになりました。
<LazyLoadingProvider
value={{
isFetchingNextPage,
hasNextPage,
onRequestNextPage: () => {
console.log('request');
}
}}
>
<Autocomplete
ListboxComponent={LazyListboxComponent}
/>
</LazyLoadingProvider>
export const LazyListboxComponent = () => {
// Context経由でvalueを受け取る
const {
isFetchingNextPage,
hasNextPage,
onRequestNextPage
} = useLazyLoadingContextValue();
}
optionsをキャッシュするhooksを用意する
次はfetchするところに入りたいところですが、その前にoptionsをキャッシュするhooksを用意しておきたいと思います。react-queryを使ってレスポンスはキャッシュすることができますが、検索ワードに応じて得られるデータが異なるためどうしても作り直しが発生します。
最初はuseInfiniteQueryを使ってみたのですが、以下のようになってしまい、自前でもキャッシュするロジックを用意する必要があると感じました。
キャッシュするhooksは以下のように書きました。optionsの追加はappendOptions
というメソッドを呼び出す形にしています。引数に現在のoptionsを渡す運用でも良かったのですが、後述する初期値表示用の取得対応で通常のoptionsとは別軸で追加したいケースがあったのでメソッド経由にしています。
import { useState, useCallback } from "react";
export type Option = {
id: number;
label: string;
};
/**
* 既出のオプションはキャッシュするhooks
*/
export const useCachedOptions = () => {
const [cachedOptions, setCachedOptions] = useState<Option[]>([]);
const appendOptions = useCallback((options: Option[]) => {
setCachedOptions((cachedOptions) => {
const newOptions = [...cachedOptions];
options.forEach((option) => {
const sameOptionIndex = newOptions.findIndex(
(opt) => opt.id === option.id
);
// IDが同じoptionは上書きする
// (新しいオブジェクトを代入するとselectedOptionの参照が新しくなるため、意図的に上書きしている)
if (sameOptionIndex >= 0) {
Object.assign(newOptions[sameOptionIndex], option);
}
// 新しいIDの場合は末尾にoptionを追加する
else {
newOptions.push(option);
}
});
return newOptions;
});
}, []);
return {
cachedOptions,
appendOptions
};
};
追加読み込みしていけるhooksを用意する
いよいよ本題の追加読み込みしていくロジックを作っていきたいと思います。内部にpage番号を持っておき、requestNextPageが実行されるたびにpage番号を繰り上げていけばあとはreact-queryの方で自動でfetchしてくれるようになります。検索ワードが変わった場合はまたpage番号をリセットする必要がありますが、そのトリガーはdebounceを入れておいて一定時間経ったら発動するようにしておきます。
ここで取得されたoptionsを前のセクションで説明したuseCachedOptionsのappendOptionsに追加していけば最終的に表示されるoptionsが出来上がり、これをreturnしてAutocompleteに渡すことになります。
import { UseQueryResult } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { Option, useCachedOptions } from "./useCachedOptions";
export type UseQueryPaginatedOptionsResponse = {
options: Option[];
hasNextPage: boolean;
};
/** ページネーションでfetchするreact-queryをラップしたhooks */
export type UseQueryPaginatedOptions = (
page: number,
searchWord: string
) => UseQueryResult<UseQueryPaginatedOptionsResponse>;
export type UseLazyOptionsOption = {
/** 検索文字列 */
searchWord: string;
/** ページネーションでfetchするreact-queryをラップしたhooks */
useQueryPaginatedOptions: UseQueryPaginatedOptions;
/** 検索文字列入力後、optionsをfetchするまでのdebounce time(ms) */
debounceSearchTime: number;
};
/**
* 非同期に選択肢を取得するhooks
*/
export const useLazyOptions = ({
searchWord,
useQueryPaginatedOptions,
debounceSearchTime
}: UseLazyOptionsOption) => {
const [lazyPageOption, setLazyPageOption] = useState<{
page: number;
hasNextpage: boolean | null;
debouncedSearchWord: string;
}>({
page: 0,
hasNextPage: null,
debouncedSearchWord: searchWord
});
const { page, hasNextPage, debouncedSearchWord } = lazyPageOption;
useEffect(() => {
const timerId = setTimeout(() => {
setLazyPageOption((currentLazyPageOption) => {
// もしdebouncedSearchWordが変わっていなければ、更新せずにそのまま返す
if (currentLazyPageOption.debouncedSearchWord === searchWord) {
return currentLazyPageOption;
}
return {
debouncedSearchWord: searchWord,
page: 0,
hasNextPage: null
};
});
}, debounceSearchTime);
return () => {
clearTimeout(timerId);
};
}, [searchWord, debounceSearchTime]);
const { cachedOptions, appendOptions } = useCachedOptions();
const {
isLoading: isLoadingPagination,
isFetching: isFetchingPagination,
data
} = useQueryPaginatedOptions(page, debouncedSearchWord);
useEffect(() => {
if (isFetchingPagination || data == null) {
return;
}
appendOptions(data.options);
setLazyPageOption((currentLazyPageOption) => ({
...currentLazyPageOption,
hasNextPage: data.hasNextPage
}));
}, [data, isFetchingPagination, appendOptions]);
return {
isLoading: isLoadingPagination,
isFetching: isFetchingPagination,
hasNextPage,
options: cachedOptions,
requestNextPage: () => {
if (hasNextPage && !isFetchingPagination) {
setLazyPageOption((currentLazyPageOption) => ({
...currentLazyPageOption,
page: currentLazyPageOption.page + 1
}));
}
}
};
};
なお、今回サンプルで用意したページネーション用のquery hooksは以下になります。重要なコードではないので、見たい方は開いてご覧ください。
useQuerySamplePaginatedOptions.ts
import { useQuery } from "@tanstack/react-query";
import { UseQueryPaginatedOptions } from "./components/useLazyOptions";
import { Option } from "./components/useCachedOptions";
export const TOTAL_OPTIONS = [...new Array(100)].map(
(_, index): Option => ({
id: index + 1,
label: `項目${index + 1}`
})
);
const PER_PAGE = 5;
const fetchSampleOptions = async (page: number, searchWord: string) => {
// 仮の遅延を入れる
await new Promise((resolve) => setTimeout(resolve, 1000));
const filteredOptions = TOTAL_OPTIONS.filter((option) =>
option.label.includes(searchWord)
);
return {
options: filteredOptions.slice(PER_PAGE * page, PER_PAGE * (page + 1)),
totalCount: filteredOptions.length
};
};
/**
* 動作確認用のサンプルページネーションをするhooks
*/
export const useQuerySamplePaginatedOptions: UseQueryPaginatedOptions = (
page: number,
searchWord: string
) => {
return useQuery({
queryKey: ["options", page, searchWord],
queryFn: async () => {
const data = await fetchSampleOptions(page, searchWord);
return {
...data,
hasNextPage: (page + 1) * PER_PAGE < data.totalCount
};
},
keepPreviousData: true
});
};
これを実際にAutocompleteとして使えるようにコンポーネントを用意すると以下のようになります。
import {
Autocomplete,
TextField,
InputAdornment,
CircularProgress
} from "@mui/material";
import { FC, useState, useMemo, useEffect } from "react";
import {
useLazyOptions,
UseQueryPaginatedOptions
} from "../useLazyOptions";
import { LazyLoadingProvider } from "../LazyLoadingProvider";
import { LazyListboxComponent } from "../LazyListboxComponent";
export type LazyAutocompleteSingleProps = {
/** 選択した値 */
value: number | null;
/** ページネーションでfetchするreact-queryをラップしたhooks */
useQueryPaginatedOptions: UseQueryPaginatedOptions;
/** 検索文字列入力後、optionsをfetchするまでのdebounce time(ms) */
debounceSearchTime: number;
/** 値変更時 */
onChangeValue: (newValue: number | null) => void;
};
export const LazyAutocompleteSingle: FC<LazyAutocompleteSingleProps> = ({
value,
useQueryPaginatedOptions,
debounceSearchTime,
onChangeValue
}) => {
const [searchWord, setSearchWord] = useState("");
const {
isLoading,
isFetching,
hasNextPage,
options,
requestNextPage
} = useLazyOptions({
searchWord,
useQueryPaginatedOptions,
debounceSearchTime
});
const selectedOption = useMemo(() => {
return options.find((option) => option.id === value) ?? null;
}, [options, value]);
return (
<LazyLoadingProvider
value={{
isFetchingNextPage: isFetching,
// 未決定状態の時は次のページがある可能性があると解釈おく
hasNextPage: hasNextPage ?? true,
onRequestNextPage: () => {
requestNextPage();
}
}}
>
<Autocomplete
loading={options.length <= 0 && isFetching}
value={selectedOption}
options={options}
ListboxComponent={LazyListboxComponent}
renderInput={(params) => {
return (
<TextField
{...params}
InputProps={{
...params.InputProps,
endAdornment: (
<InputAdornment position="end" sx={{ mr: -0.5 }}>
{isLoading && (
<CircularProgress color="inherit" size={20} />
)}
{params.InputProps.endAdornment}
</InputAdornment>
)
}}
/>
);
}}
inputValue={searchWord}
onInputChange={(event, newInputValue, reason) => {
if (reason === "clear") {
onChangeValue(null);
setSearchWord("");
return;
}
setSearchWord(newInputValue);
}}
onChange={(event, newValue, reason) => {
// inputChangeのclearの方で対応するためスキップ
// こっちでハンドリングするとテキストが空文字になった時もclearしてしまう
if (reason === "clear") {
return;
}
onChangeValue(newValue != null ? newValue.id : null);
}}
/>
</LazyLoadingProvider>
);
};
細かい調整
これで基本的には動くようになりましたが、まだ調整が必要な部分がありますので、その辺を対応していきます。
初期値がある時の対応
基本的にはスクロールや検索によってoptionsは読み込んでいけば良いですが、最初から選択されているoptionがあればそれはマストで取得する必要があります。なので通常のoptionsの取得とは別に直接読み込みたいidリストを指定してoptionsを取得してappendOptionsしておきます。
+/** 初期表示用のoptionsを取得するreact-queryをラップしたhooks */
+export type UseQueryInitialOptions = (
+ /** 初期選択時のvalueリスト */
+ initialValues: number[]
+) => UseQueryResult<Option[]>;
export type UseLazyOptionsOption = {
+ /** 初期値のラベルを出すために取得するべきoptionのidリスト */
+ initialRequiredOptionIds: number[];
/** 検索文字列 */
searchWord: string;
+ /** 初期表示用のoptionsを取得するreact-queryをラップしたhooks */
+ useQueryInitialOptions: UseQueryInitialOptions;
/** ページネーションでfetchするreact-queryをラップしたhooks */
useQueryPaginatedOptions: UseQueryPaginatedOptions;
/** 検索文字列入力後、optionsをfetchするまでのdebounce time(ms) */
debounceSearchTime: number;
};
/**
* 非同期に選択肢を取得するhooks
*/
export const useLazyOptions = ({
+ initialRequiredOptionIds,
searchWord,
+ useQueryInitialOptions,
useQueryPaginatedOptions,
debounceSearchTime
}: UseLazyOptionsOption) => {
// 既出のコードは一部省略
const { cachedOptions, appendOptions } = useCachedOptions();
+ const {
+ isLoading: isLoadingInitialOptions,
+ data: initialOptions
+ } = useQueryInitialOptions(initialRequiredOptionIds);
+ useEffect(() => {
+ if (initialOptions == null) {
+ return;
+ }
+ appendOptions(initialOptions);
+ }, [initialOptions, appendOptions]);
// returnする内容は同じなので省略
}
念のためサンプルとして使用したuseQueryも載せます。重要なコードではないので、見たい方は開いてご覧ください。
useQuerySampleInitialOptions.ts
import { useQuery } from "@tanstack/react-query";
import { UseQueryInitialOptions } from "./components/useLazyOptions";
import { TOTAL_OPTIONS } from "./useQuerySamplePaginatedOptions";
const fetchSampleInitialOptions = async (values: number[]) => {
// 仮の遅延を入れる
await new Promise((resolve) => setTimeout(resolve, 1000));
const matchedOptions = TOTAL_OPTIONS.filter((option) =>
values.includes(option.id)
);
return matchedOptions;
};
/**
* 動作確認用のサンプル初期オプションを取得するhooks
*/
export const useQuerySampleInitialOptions: UseQueryInitialOptions = (
initialValues
) => {
return useQuery({
queryKey: ["initialOptions", initialValues],
queryFn: () => {
if (initialValues.length <= 0) {
return [];
}
return fetchSampleInitialOptions(initialValues);
}
});
};
これを呼び出すように調整します。
export type LazyAutocompleteSingleProps = {
/** 選択した値 */
value: number | null;
+ /** 初期表示用のoptionsを取得するreact-queryをラップしたhooks */
+ useQueryInitialOptions: UseQueryInitialOptions;
/** ページネーションでfetchするreact-queryをラップしたhooks */
useQueryPaginatedOptions: UseQueryPaginatedOptions;
/** 検索文字列入力後、optionsをfetchするまでのdebounce time(ms) */
debounceSearchTime: number;
/** 値変更時 */
onChangeValue: (newValue: number | null) => void;
};
export const LazyAutocompleteSingle: FC<LazyAutocompleteSingleProps> = ({
value,
+ useQueryInitialOptions,
useQueryPaginatedOptions,
debounceSearchTime,
onChangeValue
}) => {
+ const [initialRequiredOptionIds] = useState(() => {
+ return value != null ? [value] : [];
+ });
const [searchWord, setSearchWord] = useState("");
const {
isLoading,
isFetching,
hasNextPage,
options,
requestNextPage
} = useLazyOptions({
+ initialRequiredOptionIds,
searchWord,
+ useQueryInitialOptions,
useQueryPaginatedOptions,
debounceSearchTime
});
// 以降は内容が同じため省略
}
追加読み込みしても一つもoptionが追加されず、更に読み込む必要がある場合の対応
optionsをキャッシュするようにしているため、fetchしたoptionが既に読み込み済みで1つも追加できないケースがあります。その場合は更に追加で読み込む必要がありますが、その対応方法に非常に悩みました。「もしひとつも追加できなかったらpageを1つ増やす」みたいなことを始めは考えましたが、現在の状態を知ろうとするとuseCallbackにdepsを追加する必要が出てしまい、refでcachedOptionsを持っておくのもバグの温床になりそうで避けたいところです。
const appendOptions = useCallback((options: Option[]) => {
// cachedOptionsをメソッドの引数から取得することでuseCallbackのdepsに登録せず済んでいるが、
// appendOptionsで追加されたかの判定をreturnすることができない
setCachedOptions((cachedOptions) => {
const newOptions = [...cachedOptions];
options.forEach((option) => {
const sameOptionIndex = newOptions.findIndex(
(opt) => opt.id === option.id
);
// IDが同じoptionは上書きする
// (新しいオブジェクトを代入するとselectedOptionの参照が新しくなるため、意図的に上書きしている)
if (sameOptionIndex >= 0) {
Object.assign(newOptions[sameOptionIndex], option);
}
// 新しいIDの場合は末尾にoptionを追加する
else {
newOptions.push(option);
}
});
return newOptions;
});
}, []);
そもそもoptionsを1つ追加できたとしても、リストボックス領域が広くてまだローディングアイコンが見ている状態であれば追加読み込みしたいです。色々悩んだ結果、refreshKeyを用意して、これをappendOptions実行後に更新することでInViewコンポーネントを再生成して改めてinView状態かをチェックするようにしました。
/**
* 既出のオプションはキャッシュするhooks
*/
export const useCachedOptions = () => {
+ const [refreshKey, setRefreshKey] = useState(performance.now().toString());
const [cachedOptions, setCachedOptions] = useState<Option[]>([]);
const appendOptions = useCallback((options: Option[]) => {
setCachedOptions((cachedOptions) => {
// 既出のコードなので省略
});
+ setRefreshKey(performance.now().toString());
}, []);
return {
+ refreshKey,
cachedOptions,
appendOptions
};
};
export const LazyListboxComponent = forwardRef<
HTMLUListElement,
HTMLAttributes<HTMLElement>
>(({ children, ...restProps }, ref) => {
const {
+ refreshKey,
isFetchingNextPage,
hasNextPage,
onRequestNextPage
} = useLazyLoadingContextValue();
return (
<ul ref={ref} {...restProps}>
{children}
<InView
+ key={refreshKey}
onChange={(inView, entry) => {
if (!inView) {
return;
}
if (!isFetchingNextPage && hasNextPage) {
onRequestNextPage();
}
}}
>
{hasNextPage && (
<Box component="li" sx={{ pt: 1, textAlign: "center" }}>
<CircularProgress size={25} />
</Box>
)}
</InView>
</ul>
);
});
これでこんな感じでInviewがなくなるまで追加読み込みしてくれるようになりました。作り直しているためローディング表示もやり直ししちゃっていますが、再読み込みしていると逆に分かるので一旦これでも大丈夫かなと思っています。もしこれが気になる場合はInViewとローディング表示を別々にするか、そもそも再追加読み込みのトリガー条件を変える必要がありそうです。
複数選択のパターン対応
今までは単一のAutocompleteでしたが、複数版も用意します。useLazyOptionsは単数・複数に影響がないので、同じhooksを使い回すことができ、コードだと以下のようになります。単一版の時とそこまで大きな違いがないので一旦折りたたんだ状態で載せておきます。コードが気になる方は開いてご覧ください。
複数選択版のLazyAutocomplete
import {
Autocomplete,
Checkbox,
Chip,
TextField,
InputAdornment,
CircularProgress
} from "@mui/material";
import { FC, useState, useMemo } from "react";
import {
useLazyOptions,
UseQueryInitialOptions,
UseQueryPaginatedOptions
} from "../useLazyOptions";
import { LazyLoadingProvider } from "../LazyLoadingProvider";
import { LazyListboxComponent } from "../LazyListboxComponent";
export type LazyAutocompleteMultipleProps = {
/** 選択した値 */
values: number[];
/** 初期表示用のoptionsを取得するreact-queryをラップしたhooks */
useQueryInitialOptions: UseQueryInitialOptions;
/** ページネーションでfetchするreact-queryをラップしたhooks */
useQueryPaginatedOptions: UseQueryPaginatedOptions;
/** 検索文字列入力後、optionsをfetchするまでのdebounce time(ms) */
debounceSearchTime: number;
/** 値変更時 */
onChangeValues: (newValues: number[]) => void;
};
export const LazyAutocompleteMultiple: FC<LazyAutocompleteMultipleProps> = ({
values,
useQueryInitialOptions,
useQueryPaginatedOptions,
debounceSearchTime,
onChangeValues
}) => {
const [initialRequiredOptionIds] = useState(() => values);
const [searchWord, setSearchWord] = useState("");
const {
refreshKey,
isLoading,
isFetching,
hasNextPage,
options,
requestNextPage
} = useLazyOptions({
initialRequiredOptionIds,
searchWord,
useQueryInitialOptions,
useQueryPaginatedOptions,
debounceSearchTime
});
const selectedOptions = useMemo(() => {
return options.filter((option) => values.includes(option.id));
}, [options, values]);
return (
<LazyLoadingProvider
value={{
refreshKey,
isFetchingNextPage: isFetching,
// 未決定状態の時は次のページがある可能性があると解釈おく
hasNextPage: hasNextPage ?? true,
onRequestNextPage: () => {
requestNextPage();
}
}}
>
<Autocomplete
loading={options.length <= 0 && isFetching}
multiple
disableCloseOnSelect
value={selectedOptions}
options={options}
ListboxComponent={LazyListboxComponent}
renderOption={(props, option, { selected }) => {
return (
<li {...props}>
<Checkbox checked={selected} />
{option.label}
</li>
);
}}
renderTags={(tagValues, getTagProps) => {
return tagValues.map((option, index) => {
const tagProps = getTagProps({ index });
return <Chip {...tagProps} label={option.label} />;
});
}}
renderInput={(params) => {
return (
<TextField
{...params}
InputProps={{
...params.InputProps,
endAdornment: (
<InputAdornment position="end">
{isLoading && (
<CircularProgress color="inherit" size={20} />
)}
{params.InputProps.endAdornment}
</InputAdornment>
)
}}
/>
);
}}
inputValue={searchWord}
onInputChange={(event, newInputValue, reason) => {
console.log(reason, newInputValue);
if (reason === "reset") {
return;
}
setSearchWord(newInputValue);
}}
onChange={(event, newValues) => {
onChangeValues(newValues.map((value) => value.id));
}}
onClose={() => {
setSearchWord("");
}}
/>
</LazyLoadingProvider>
);
};
終わりに
以上がMUIのAutocompleteでoptionsを無限スクロールする方法でした。結構実装は大変でしたが、一度コンポーネント化しておけば使う側としては特に気にせず使えるので、こういった機能が必要な場合に参考になれば幸いです。
Discussion