Files
supabase/apps/ui-library/public/r/infinite-query-hook.json
T
95ec79b98f feat: Infinite query hook block (#34650)
* infinite list

* infinite list block

* registration

* add missing supportedFrameworks

* Add tables to the supabase project. Generate the types for it.

* Refactor the infinite list query to be just a hook.

* Clean up the block. Add comments.

* Regenerate the registry.

* Fix the docs, the block is not framework-dependent.

* Set the package versions to * to be defined by other packages.

* Minor fixes to the block.

* Fix the examples.

* Fix the docs for the new hook.

* Fix the demo.

* Add more migrations to the db.

* Fix various issues with the query. Rewrote it to useSyncExternalStore.

* Fix the SSR for the hook.

* More fixes.

* Try initializing the store in a useEffect.

* Fix the pnpm-lock file.

* Minor fixes in the docs.

* Put the infinite list under a reusable components section.

* Update apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts

* Change the example DB to use todos.

* Update the docs to be about Todos quickstart.

* List edits

* Fix link

* Regenerate the registry.

* Add query hook to the landing page.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
Co-authored-by: Terry Sutton <saltcod@gmail.com>
2025-04-17 15:18:33 +02:00

19 lines
6.5 KiB
JSON

{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "infinite-query-hook",
"type": "registry:block",
"title": "Infinite Query Hook",
"description": "React hook for infinite lists, fetching data from Supabase.",
"dependencies": [
"@supabase/supabase-js",
"@supabase/postgrest-js@*"
],
"registryDependencies": [],
"files": [
{
"path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts",
"content": "'use client'\n\nimport { createClient } from '@/registry/default/fixtures/lib/supabase/client'\nimport { PostgrestQueryBuilder } from '@supabase/postgrest-js'\nimport { SupabaseClient } from '@supabase/supabase-js'\nimport { useEffect, useRef, useSyncExternalStore } from 'react'\n\nconst supabase = createClient()\n\n// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.\ntype SupabaseClientType = typeof supabase\n\n// Utility type to check if the type is any\ntype IfAny<T, Y, N> = 0 extends 1 & T ? Y : N\n\n// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.\ntype Database =\n SupabaseClientType extends SupabaseClient<infer U>\n ? IfAny<\n U,\n {\n public: {\n Tables: Record<string, any>\n Views: Record<string, any>\n Functions: Record<string, any>\n }\n },\n U\n >\n : never\n\n// Change this to the database schema you want to use\ntype DatabaseSchema = Database['public']\n\n// Extracts the table names from the database type\ntype SupabaseTableName = keyof DatabaseSchema['Tables']\n\n// Extracts the table definition from the database type\ntype SupabaseTableData<T extends SupabaseTableName> = DatabaseSchema['Tables'][T]['Row']\n\ntype SupabaseSelectBuilder<T extends SupabaseTableName> = ReturnType<\n PostgrestQueryBuilder<DatabaseSchema, DatabaseSchema['Tables'][T], T>['select']\n>\n\n// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\ntype SupabaseQueryHandler<T extends SupabaseTableName> = (\n query: SupabaseSelectBuilder<T>\n) => SupabaseSelectBuilder<T>\n\ninterface UseInfiniteQueryProps<T extends SupabaseTableName, Query extends string = '*'> {\n // The table name to query\n tableName: T\n // The columns to select, defaults to `*`\n columns?: string\n // The number of items to fetch per page, defaults to `20`\n pageSize?: number\n // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\n trailingQuery?: SupabaseQueryHandler<T>\n}\n\ninterface StoreState<TData> {\n data: TData[]\n count: number\n isSuccess: boolean\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n hasInitialFetch: boolean\n}\n\ntype Listener = () => void\n\nfunction createStore<TData extends SupabaseTableData<T>, T extends SupabaseTableName>(\n props: UseInfiniteQueryProps<T>\n) {\n const { tableName, columns = '*', pageSize = 20, trailingQuery } = props\n\n let state: StoreState<TData> = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n }\n\n const listeners = new Set<Listener>()\n\n const notify = () => {\n listeners.forEach((listener) => listener())\n }\n\n const setState = (newState: Partial<StoreState<TData>>) => {\n state = { ...state, ...newState }\n notify()\n }\n\n const fetchPage = async (skip: number) => {\n if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return\n\n setState({ isFetching: true })\n\n let query = supabase\n .from(tableName)\n .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder<T>\n\n if (trailingQuery) {\n query = trailingQuery(query)\n }\n const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)\n\n if (error) {\n console.error('An unexpected error occurred:', error)\n setState({ error })\n } else {\n const deduplicatedData = ((newData || []) as TData[]).filter(\n (item) => !state.data.find((old) => old.id === item.id)\n )\n\n setState({\n data: [...state.data, ...deduplicatedData],\n count: count || 0,\n isSuccess: true,\n error: null,\n })\n }\n setState({ isFetching: false })\n }\n\n const fetchNextPage = async () => {\n if (state.isFetching) return\n await fetchPage(state.data.length)\n }\n\n const initialize = async () => {\n setState({ isLoading: true, isSuccess: false, data: [] })\n await fetchNextPage()\n setState({ isLoading: false, hasInitialFetch: true })\n }\n\n return {\n getState: () => state,\n subscribe: (listener: Listener) => {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n fetchNextPage,\n initialize,\n }\n}\n\n// Empty initial state to avoid hydration errors.\nconst initialState: any = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n}\n\nfunction useInfiniteQuery<\n TData extends SupabaseTableData<T>,\n T extends SupabaseTableName = SupabaseTableName,\n>(props: UseInfiniteQueryProps<T>) {\n const storeRef = useRef(createStore<TData, T>(props))\n\n const state = useSyncExternalStore(\n storeRef.current.subscribe,\n () => storeRef.current.getState(),\n () => initialState as StoreState<TData>\n )\n\n useEffect(() => {\n // Recreate store if props change\n if (\n storeRef.current.getState().hasInitialFetch &&\n (props.tableName !== props.tableName ||\n props.columns !== props.columns ||\n props.pageSize !== props.pageSize)\n ) {\n storeRef.current = createStore<TData, T>(props)\n }\n\n if (!state.hasInitialFetch && typeof window !== 'undefined') {\n storeRef.current.initialize()\n }\n }, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch])\n\n return {\n data: state.data,\n count: state.count,\n isSuccess: state.isSuccess,\n isLoading: state.isLoading,\n isFetching: state.isFetching,\n error: state.error,\n hasMore: state.count > state.data.length,\n fetchNextPage: storeRef.current.fetchNextPage,\n }\n}\n\nexport {\n useInfiniteQuery,\n type SupabaseQueryHandler,\n type SupabaseTableData,\n type SupabaseTableName,\n type UseInfiniteQueryProps,\n}\n",
"type": "registry:hook"
}
]
}