diff --git a/packages/react-use/src/use-infinite-list/demo.tsx b/packages/react-use/src/use-infinite-list/demo.tsx index 0581f35..d9b9ebf 100644 --- a/packages/react-use/src/use-infinite-list/demo.tsx +++ b/packages/react-use/src/use-infinite-list/demo.tsx @@ -1,24 +1,29 @@ -import { Card, KeyValue, Zone, wait } from '@/components' -import { useInfiniteList } from '@shined/react-use' +import { Button, Card, Input, KeyValue, Zone, cn, wait } from '@/components' +import { generateLoremIpsum, useInfiniteList, useUpdateEffect } from '@shined/react-use' import { useRef } from 'react' -interface Item { - id: number - name: string +interface Data { + data: { id: number; name: string }[] + total: number } +const genders = ['Boy', 'Girl'] as const +const colors = ['Red', 'Orange', 'Yellow', 'Green', 'Cyan', 'Blue', 'Violet'] as const + export function App() { const ref = useRef(null) - const infiniteList = useInfiniteList< - { data: Item[]; total: number }, + const { form, list, fullList, paginationState, selection, loading, isLoadDone } = useInfiniteList< + Data, + Data['data'][number], { name: string; gender: string; color: string[] } >({ target: ref, fetcher: fetchPagination, - canLoadMore: (previousData, dataList) => { + mapFullList: (d) => d.data, + canLoadMore: (previousData, dataList, fullList) => { if (!previousData) return true // initial load - return dataList.length * 10 < previousData.total + return fullList.length < previousData.total }, form: { initialValue: { @@ -27,40 +32,177 @@ export function App() { color: ['Red'], }, }, - pagination: { - page: 1, - pageSize: 10, - }, + pagination: { pageSize: 10 }, immediateQueryKeys: ['color', 'gender'], }) + // when you use third-party components, you can use `selection.isPartiallySelected` directly + useUpdateEffect(() => { + const selectAllInput = document.querySelector('input[name="select-all"]') as HTMLInputElement + selectAllInput.indeterminate = selection.isPartiallySelected + }, [selection.isPartiallySelected]) + return ( - -
- {infiniteList.list.map((e) => { - return e.data.map((item) => ( -
+

1. Scroll to Load More

+
+ + + Name: + + + + + + Gender: + {genders.map((gender) => ( + + ))} + + + Color: + {colors.map((color) => ( + + ))} + + +
+ + + + + e.data).length} /> + + + + + + + + + +
+ {fullList.map((item) => { + return ( +
selection.toggle(item)} + > + { + selection.toggle(item) + }} + checked={selection.isItemSelected(item)} + /> {item.id} - {item.name}
- )) + ) })} - {infiniteList.isLoading && ( -
Loading...
- )} - {infiniteList.isLoadDone &&
No more data
} + {loading &&
Loading...
} + {isLoadDone &&
No more data
}
+ + + {selection.selected.map((item) => ( +
{item.name}
+ ))} + {selection.selected.length === 0 &&
No selected
} +
+ +
+ +

2. Click to Load More

+ ) } -async function fetchPagination(params: { - page: number - pageSize: number -}): Promise<{ data: Item[]; total: number }> { - console.log('fetching data', params.page, params) +function LoadMoreList() { + const { loadMore, fullList, loading, isLoadDone } = useInfiniteList({ + fetcher: fetchPagination, + mapFullList: (d) => d.data, + canLoadMore: (previousData, dataList, fullList) => { + if (!previousData) return true // initial load + return fullList.length < previousData.total + }, + }) + + return ( +
+ {fullList.map((item) => { + return ( +
+ {item.id} - {item.name} +
+ ) + })} + + {isLoadDone ? ( +
No more data
+ ) : ( + + )} +
+ ) +} +async function fetchPagination(params: { page: number; pageSize: number }): Promise { await wait(600) const total = 57 @@ -72,7 +214,7 @@ async function fetchPagination(params: { return { data: Array.from({ length: returnLength }).map((_, i) => ({ id: startIdx + i + 1, - name: `name-${startIdx + i + 1}`, + name: generateLoremIpsum(), })), total, } diff --git a/packages/react-use/src/use-infinite-list/index.mdx b/packages/react-use/src/use-infinite-list/index.mdx index d443dd9..d48261c 100644 --- a/packages/react-use/src/use-infinite-list/index.mdx +++ b/packages/react-use/src/use-infinite-list/index.mdx @@ -4,28 +4,173 @@ category: ProUtilities # useInfiniteList -import { HooksType } from '@/components' +import { HooksType, Since } from '@/components' -## Demo + + +A React Hook designed to handle infinite lists, supporting scenarios such as infinite scrolling, click-to-load-more, and equipped with features for data fetching, bottom detection, form management, pagination, multi-selection, and loading status. + +`useInfiniteList` is a higher-order encapsulation of the following Hooks: + +- [useInfiniteScroll](/reference/use-infinite-scroll) offers bottom detection and data loading completion checks. +- [useForm](/reference/use-form) provides form management functionalities for search, filtering, etc. +- [useAsyncFn](/reference/use-async-fn) offers data fetching and lifecycle management functionalities. +- [usePagination](/reference/use-pagination) manages pagination states and parameters. +- [useMultiSelect](/reference/use-multi-select) provides multi-selection states, commonly used for batch operations. + +## Scenes \{#scenes} + +`useInfiniteList` = Infinite Scrolling / Click-to-Load + Automatic Pagination Management + Form Filtering (Optional) + Multi-Select Operations (Optional) + +## Demo \{#demo} import { App } from './demo' -## Usage +## Usage \{#usage} + +```tsx +interface Item { id: number; name: string; } +interface Data { data: Item[]; total: number; } +interface FormState { name: string; gender: string; color: string[]; } -See API for more details. +const ref = useRef(null) // The target element for bottom detection, needed only for scroll loading -## Source +const { + list, // The list of return values composed of the data returned by fetcher, Data[] + fullList, // The item list formed by unfolding the list in the data returned by fetcher, Item[] + form, // Form state and operations + selection, // Multi-selection state and operations + paginationState, // Pagination state, information of the requested page + loading, // Loading state + isLoadDone // Whether the loading is completed +} = useInfiniteList({ + target: ref, // The target element for bottom detection, needed only for scroll loading + fetcher: fetchPagination, // Function to fetch data + mapFullList: (d) => d.data, // Map function for obtaining data list from every return + canLoadMore: (previousData, dataList, fullList) => { + if (!previousData) return true // If there is no result from the last request, it means it's the first load + // Continue loading if the current list length is less than the total, otherwise stop loading + return fullList.length < previousData.total + }, + form: { // Configure useForm + initialValue: { + name: '', + gender: 'Boy', + color: ['Red'], + }, + }, + pagination: { pageSize: 10 }, // Configure usePagination + immediateQueryKeys: ['color', 'gender'], // Form fields for immediate querying +}) + +return ( +
+ +
+ +
+
+) +``` + +## Source \{#source} import { Source } from '@/components' -## API +## API \{#api} ```tsx +const { + list, fullList, form, selection, + paginationState, loading, isLoadDone +} = useInfiniteList(options) +``` + +### Options \{#options} +```tsx +export interface UseInfiniteListOptions< + Data, + Item, + FormState extends object, + Fetcher extends AnyFunc, + Container extends HTMLElement, +> { + /** + * The container element for bottom detection + */ + target?: ElementTarget + /** + * Data fetching function, able to return data similar to { data: Item[], total: number } + */ + fetcher?: Fetcher + /** + * Data mapping function, for obtaining the data list from each return + */ + mapFullList?: (data: Data) => Item[] + /** + * Form options, for configuring `useForm`, refer to `useForm` for more details + */ + form?: UseFormOptions + /** + * Asynchronous function options, for configuring `useAsyncFn`, refer to `useAsyncFn` for more details + */ + asyncFn?: Omit, 'initialParams'> + /** + * Pagination data options, for configuring `usePagination`, refer to `usePagination` for more details + */ + pagination?: UsePaginationOptions + /** + * Function to decide whether more items can be loaded + */ + canLoadMore?: (previousData: Data | undefined, dataList: Data[], fullList: Item[]) => boolean + /** + * Scroll loading options, for configuring `useInfiniteScroll`, refer to `useInfiniteScroll` for more details + */ + infiniteScroll?: Omit, 'canLoadMore'> + /** + * Immediate query form fields, when form fields change, reset data and start a new round of queries + */ + immediateQueryKeys?: (keyof FormState)[] +} +``` + +### Returns \{#returns} + +The return value includes [useInfiniteScroll](/reference/use-infinite-scroll)'s return value, refer to [useInfiniteScroll](/reference/use-infinite-scroll) for more details. + +```tsx +export interface UseInfiniteListReturns + extends Omit { + /** + * Resets all states and restarts querying + */ + reset: () => void + /** + * Form state and operations + */ + form: UseFormReturns + /** + * Loading state + */ + list: Data[] + /** + * Whether the loading is completed + */ + fullList: Item[] + /** + * Multi-selection state and operations + */ + selection: UseMultiSelectReturnsState & UseMultiSelectReturnsActions + /** + * Pagination state + */ + paginationState: UsePaginationReturnsState +} ``` diff --git a/packages/react-use/src/use-infinite-list/index.ts b/packages/react-use/src/use-infinite-list/index.ts index a116c57..e964d16 100644 --- a/packages/react-use/src/use-infinite-list/index.ts +++ b/packages/react-use/src/use-infinite-list/index.ts @@ -1,37 +1,76 @@ -import { useRef } from 'react' +import { useMemo, useRef } from 'react' +import { useAsyncFn } from '../use-async-fn' import { useForm } from '../use-form' import { useInfiniteScroll } from '../use-infinite-scroll' import { useLatest } from '../use-latest' import { useMultiSelect } from '../use-multi-select' import { usePagination } from '../use-pagination' -import { useQuery } from '../use-query' -import { useSafeState } from '../use-safe-state' +import { useStableFn } from '../use-stable-fn' +import { useTrackedRefState } from '../use-tracked-ref-state' import { shallowEqual } from '../utils/equal' -import type { UseFormOptions } from '../use-form' -import type { UseInfiniteScrollOptions } from '../use-infinite-scroll' -import type { UsePaginationOptions } from '../use-pagination' -import type { UseQueryOptions } from '../use-query' +import type { UseAsyncFnOptions } from '../use-async-fn' +import type { UseFormOptions, UseFormReturns } from '../use-form' +import type { UseInfiniteScrollOptions, UseInfiniteScrollReturns } from '../use-infinite-scroll' +import type { UseMultiSelectReturnsActions, UseMultiSelectReturnsState } from '../use-multi-select' +import type { UsePaginationOptions, UsePaginationReturnsState } from '../use-pagination' import type { ElementTarget } from '../use-target-element' import type { AnyFunc } from '../utils/basic' export interface UseInfiniteListOptions< - Fetcher extends AnyFunc, Data, + Item, FormState extends object, + Fetcher extends AnyFunc, Container extends HTMLElement, > { + /** + * The container element + */ target?: ElementTarget + /** + * The fetcher function, should return a object with data item list. + */ fetcher?: Fetcher + /** + * The map function to map each data to item list + */ + mapFullList?: (data: Data) => Item[] + /** + * The form options + * + * see `useForm` for more details + */ form?: UseFormOptions - query?: Omit, 'initialParams'> + /** + * The async function options + * + * see `useAsyncFn` for more details + */ + asyncFn?: Omit, 'initialParams'> + /** + * The pagination options + * + * see `usePagination` for more details + */ pagination?: UsePaginationOptions - canLoadMore?: (previousData: Data | undefined, dataList: Data[]) => boolean + /** + * Whether can load more + */ + canLoadMore?: (previousData: Data | undefined, dataList: Data[], fullList: Item[]) => boolean + /** + * The infinite scroll options + * + * see `useInfiniteScroll` for more details + */ infiniteScroll?: Omit, 'canLoadMore'> + /** + * The keys of form state that will trigger a new query when changed + */ immediateQueryKeys?: (keyof FormState)[] } -export interface UseInfiniteListFetcherParams { +export interface UseInfiniteListFetcherParams { /** * previous data */ @@ -50,11 +89,37 @@ export interface UseInfiniteListFetcherParams { form: FormState } -export type UseInfiniteListFetcher = ( - params: UseInfiniteListFetcherParams, +export type UseInfiniteListFetcher = ( + params: UseInfiniteListFetcherParams, ) => Promise -// useInfiniteList = useInfiniteScroll + useForm + useQuery + usePagination + useMultiSelect +export interface UseInfiniteListReturns + extends Omit { + /** + * reset the list, form, pagination, selection, and refetch the data + */ + reset: () => void + /** + * The form state + */ + form: UseFormReturns + /** + * The list data + */ + list: Data[] + /** + * The full list data + */ + fullList: Item[] + /** + * The selection state and action + */ + selection: UseMultiSelectReturnsState & UseMultiSelectReturnsActions + /** + * The pagination state + */ + paginationState: UsePaginationReturnsState +} /** * @@ -62,13 +127,17 @@ export type UseInfiniteListFetcher = ( */ export function useInfiniteList< Data, + Item = any, FormState extends object = object, - Fetcher extends UseInfiniteListFetcher = UseInfiniteListFetcher, + Fetcher extends UseInfiniteListFetcher = UseInfiniteListFetcher, Container extends HTMLElement = HTMLElement, ->(options: UseInfiniteListOptions = {}) { +>( + options: UseInfiniteListOptions = {}, +): UseInfiniteListReturns { const { target, fetcher = () => {} } = options - const previousData = useRef(undefined) + const [{ dataList }, { updateRefState }, stateRef] = useTrackedRefState<{ dataList: Data[] }>({ dataList: [] }) + const previousDataRef = useRef(undefined) const previousFormRef = useRef((options.form?.initialValue || {}) as FormState) const form = useForm({ @@ -81,7 +150,7 @@ export function useInfiniteList< const isChanged = !shallowEqual(previousFormRef.current[key], nextForm[key]) if (isChanged) { - startNewQuery() + reset() break } } @@ -91,18 +160,18 @@ export function useInfiniteList< return latest.current.options.form?.onChange?.(form, ...args) }, onSubmit: (form) => { - startNewQuery() + reset() return latest.current.options.form?.onSubmit?.(form) }, onReset: () => { - startNewQuery() + reset() return latest.current.options.form?.onReset?.() }, }) - const query = useQuery(fetcher as Fetcher, { - ...options.query, - immediate: options.query?.immediate ?? !options.target, + const loadFn = useAsyncFn(fetcher as Fetcher, { + ...options.asyncFn, + immediate: !options.target, initialParams: [ { page: options.pagination?.page ?? 1, @@ -112,52 +181,62 @@ export function useInfiniteList< }, ] as Parameters, onSuccess(data, ...reset) { - previousData.current = data - setDataList((prev) => [...prev, data]) + updateRefState('dataList', [...stateRef.dataList.value, data]) paginationActions.next() - latest.current.options.query?.onSuccess?.(data, ...reset) + previousDataRef.current = data + return latest.current.options.asyncFn?.onSuccess?.(data, ...reset) }, }) - const [dataList, setDataList] = useSafeState([]) - const infiniteScroll = useInfiniteScroll( target, () => { - return query.run({ + return loadFn.run({ form: form.value, page: latest.current.paginationState.page ?? 10, pageSize: latest.current.paginationState.pageSize ?? 10, - previousData: previousData.current, + previousData: previousDataRef.current, }) }, { ...options.infiniteScroll, canLoadMore: (pre) => { - return latest.current.options.canLoadMore?.(pre, latest.current.dataList) ?? true + const dataList = stateRef.dataList.value + const fullList = dataList.flatMap((data) => latestMapFullList.current?.(data) ?? []) as Item[] + return latest.current.options.canLoadMore?.(pre, dataList, fullList) ?? true }, }, ) - function startNewQuery() { - setDataList([]) + const reset = useStableFn(() => { + loadFn.cancel() + previousDataRef.current = undefined + updateRefState('dataList', []) + selectActions.setSelected([]) paginationActions.go(1) - previousData.current = undefined - infiniteScroll.reset(true) - } + infiniteScroll.reset() + !latest.current.options.target && infiniteScroll.loadMore() + }) + + const latestMapFullList = useLatest(options.mapFullList) - const [selectState, selectActions] = useMultiSelect(dataList) + const fullList = useMemo(() => { + return dataList.flatMap((data) => latestMapFullList.current?.(data) ?? []) as Item[] + }, [dataList]) + + const [selectState, selectActions] = useMultiSelect(fullList) const [paginationState, paginationActions] = usePagination({ ...options.pagination, onPageSizeChange: (paging) => { - startNewQuery() - latest.current.options.pagination?.onPageSizeChange?.(paging) + reset() + return latest.current.options.pagination?.onPageSizeChange?.(paging) }, }) const latest = useLatest({ dataList, + fullList, options, selectState, paginationState, @@ -165,16 +244,14 @@ export function useInfiniteList< return { ...infiniteScroll, - list: dataList, + reset, form, - query, + list: dataList, + fullList, + paginationState, selection: { ...selectState, ...selectActions, }, - pagination: { - ...paginationState, - ...paginationActions, - }, } } diff --git a/packages/react-use/src/use-infinite-list/index.zh-cn.mdx b/packages/react-use/src/use-infinite-list/index.zh-cn.mdx index e228b38..4919654 100644 --- a/packages/react-use/src/use-infinite-list/index.zh-cn.mdx +++ b/packages/react-use/src/use-infinite-list/index.zh-cn.mdx @@ -9,16 +9,20 @@ import { HooksType, Since } from '@/components' -{/* + 一个用来处理无限列表的 React Hook,支持无限滚动、点击加载更多等场景,内置了数据请求、探底检测、表单管理、分页、多选、加载状态等功能。 `useInfiniteList` 是以下几个 Hooks 的上层封装: - [useInfiniteScroll](/reference/use-infinite-scroll) 提供探底检测、数据是否加载完毕等功能 - [useForm](/reference/use-form) 提供表单管理功能,用于搜索、筛选等 -- [useQuery](/reference/use-query) 提供数据请求功能,缓存、自动刷新等等 +- [useAsyncFn](/reference/use-async-fn) 提供数据请求、生命周期等功能 - [usePagination](/reference/use-pagination) 提供分页状态,管理分页查询参数 -- [useMultiSelect](/reference/use-multi-select) 提供多选状态,常用于多选进行批量操作 */} +- [useMultiSelect](/reference/use-multi-select) 提供多选状态,常用于多选进行批量操作 + +## 场景 \{#scenes} + +`useInfiniteList` = 无限滚动/点击加载 + 自动管理分页 + 表单查询(可选) + 多选操作(可选) ## 演示 \{#demo} @@ -28,7 +32,50 @@ import { App } from './demo' ## 用法 \{#usage} -请查看 API。 +```tsx +interface Item { id: number; name: string; } +interface Data { data: Item[]; total: number; } +interface FormState { name: string; gender: string; color: string[]; } + +const ref = useRef(null) // 用于探底检测的目标元素,仅滚动加载需要 + +const { + list, // fetcher 返回的数据组成返回值列表,Data[] + fullList, // fetcher 返回的数据里的列表展开后组成的项目列表,Item[] + form, // 表单状态和操作 + selection, // 多选状态和操作 + paginationState, // 分页状态,请求的页码信息 + loading, // 加载状态 + isLoadDone // 是否加载完毕 +} = useInfiniteList({ + target: ref, // 用于探底检测的目标元素,仅滚动加载需要 + fetcher: fetchPagination, // 请求数据的函数 + mapFullList: (d) => d.data, // 映射数据列表的 map 函数 + canLoadMore: (previousData, dataList, fullList) => { + if (!previousData) return true // 无上次请求结果,则为首次加载 + // 当前列表长度小于总数时,可以继续加载,否则不再加载 + return fullList.length < previousData.total + }, + form: { // 配置 useForm + initialValue: { + name: '', + gender: 'Boy', + color: ['Red'], + }, + }, + pagination: { pageSize: 10 }, // 配置 usePagination + immediateQueryKeys: ['color', 'gender'], // 立即查询的表单字段 +}) + +return ( +
+ +
+ +
+
+) +``` ## 源码 \{#source} @@ -39,5 +86,91 @@ import { Source } from '@/components' ## API \{#api} ```tsx +const { + list, fullList, form, selection, + paginationState, loading, isLoadDone +} = useInfiniteList(options) +``` + +### 选项 Options \{#options} +```tsx +export interface UseInfiniteListOptions< + Data, + Item, + FormState extends object, + Fetcher extends AnyFunc, + Container extends HTMLElement, +> { + /** + * 容器元素,用于探底检测 + */ + target?: ElementTarget + /** + * 数据获取函数,可返回类似 { data: Item[], total: number } 的数据 + */ + fetcher?: Fetcher + /** + * 数据映射函数,用于获取每一次返回里的数据列表 + */ + mapFullList?: (data: Data) => Item[] + /** + * 表单选项,用于配置 `useForm`,参考 `useForm` 了解更多 + */ + form?: UseFormOptions + /** + * 异步函数选项,用于配置 `useAsyncFn`,参考 `useAsyncFn` 了解更多 + */ + asyncFn?: Omit, 'initialParams'> + /** + * 分页数据选项,用于配置 `usePagination`,参考 `usePagination` 了解更多 + */ + pagination?: UsePaginationOptions + /** + * 是否可以继续加载更多的判断函数 + */ + canLoadMore?: (previousData: Data | undefined, dataList: Data[], fullList: Item[]) => boolean + /** + * 滚动加载选项,用于配置 `useInfiniteScroll`,参考 `useInfiniteScroll` 了解更多 + */ + infiniteScroll?: Omit, 'canLoadMore'> + /** + * 立即查询的表单字段,当表单字段变化时立即重置数据并开启新的一轮查询 + */ + immediateQueryKeys?: (keyof FormState)[] +} +``` + +### 返回值 \{#returns} + +返回值包含 [useInfiniteScroll](/reference/use-infinite-scroll) 的返回值,参考 [useInfiniteScroll](/reference/use-infinite-scroll) 了解更多。 + +```tsx +export interface UseInfiniteListReturns + extends Omit { + /** + * 重置所有状态,并重新查询 + */ + reset: () => void + /** + * 表单状态和操作 + */ + form: UseFormReturns + /** + * 加载状态 + */ + list: Data[] + /** + * 是否加载完毕 + */ + fullList: Item[] + /** + * 多选状态和操作 + */ + selection: UseMultiSelectReturnsState & UseMultiSelectReturnsActions + /** + * 分页状态 + */ + paginationState: UsePaginationReturnsState +} ```