A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2import { useDidResolution } from './useDidResolution';
3import { usePdsEndpoint } from './usePdsEndpoint';
4import { createAtprotoClient } from '../utils/atproto-client';
5
6/**
7 * Record envelope returned by paginated AT Protocol queries.
8 */
9export interface PaginatedRecord<T> {
10 /** Fully qualified AT URI for the record. */
11 uri: string;
12 /** Record key extracted from the URI or provided by the API. */
13 rkey: string;
14 /** Raw record value. */
15 value: T;
16}
17
18interface PageData<T> {
19 records: PaginatedRecord<T>[];
20 cursor?: string;
21}
22
23/**
24 * Options accepted by {@link usePaginatedRecords}.
25 */
26export interface UsePaginatedRecordsOptions {
27 /** DID or handle whose repository should be queried. */
28 did?: string;
29 /** NSID collection containing the target records. */
30 collection: string;
31 /** Maximum page size to request; defaults to `5`. */
32 limit?: number;
33}
34
35/**
36 * Result returned from {@link usePaginatedRecords} describing records and pagination state.
37 */
38export interface UsePaginatedRecordsResult<T> {
39 /** Records for the active page. */
40 records: PaginatedRecord<T>[];
41 /** Indicates whether a page load is in progress. */
42 loading: boolean;
43 /** Error produced during the latest fetch, if any. */
44 error?: Error;
45 /** `true` when another page can be fetched forward. */
46 hasNext: boolean;
47 /** `true` when a previous page exists in memory. */
48 hasPrev: boolean;
49 /** Requests the next page (if available). */
50 loadNext: () => void;
51 /** Returns to the previous page when possible. */
52 loadPrev: () => void;
53 /** Index of the currently displayed page. */
54 pageIndex: number;
55 /** Number of pages fetched so far (or inferred total when known). */
56 pagesCount: number;
57}
58
59/**
60 * React hook that fetches a repository collection with cursor-based pagination and prefetching.
61 *
62 * @param did - Handle or DID whose repository should be queried.
63 * @param collection - NSID collection to read from.
64 * @param limit - Maximum number of records to request per page. Defaults to `5`.
65 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
66 */
67export function usePaginatedRecords<T>({ did: handleOrDid, collection, limit = 5 }: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
68 const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
69 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
70 const [pages, setPages] = useState<PageData<T>[]>([]);
71 const [pageIndex, setPageIndex] = useState(0);
72 const [loading, setLoading] = useState(false);
73 const [error, setError] = useState<Error | undefined>(undefined);
74 const inFlight = useRef<Set<string>>(new Set());
75 const requestSeq = useRef(0);
76
77 const resetState = useCallback(() => {
78 setPages([]);
79 setPageIndex(0);
80 setError(undefined);
81 inFlight.current.clear();
82 requestSeq.current += 1;
83 }, []);
84
85 const fetchPage = useCallback(async (cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
86 if (!did || !endpoint) return;
87 const token = requestSeq.current;
88 const key = `${targetIndex}:${cursor ?? 'start'}`;
89 if (inFlight.current.has(key)) return;
90 inFlight.current.add(key);
91 if (mode === 'active') {
92 setLoading(true);
93 setError(undefined);
94 }
95 try {
96 const { rpc } = await createAtprotoClient({ service: endpoint });
97 const res = await (rpc as unknown as {
98 get: (
99 nsid: string,
100 opts: { params: Record<string, string | number | boolean | undefined> }
101 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
102 }).get('com.atproto.repo.listRecords', {
103 params: {
104 repo: did,
105 collection,
106 limit,
107 cursor,
108 reverse: false
109 }
110 });
111 if (!res.ok) throw new Error('Failed to list records');
112 const { records, cursor: nextCursor } = res.data;
113 const mapped: PaginatedRecord<T>[] = records.map((item) => ({
114 uri: item.uri,
115 rkey: item.rkey ?? extractRkey(item.uri),
116 value: item.value
117 }));
118 if (token !== requestSeq.current) {
119 return nextCursor;
120 }
121 if (mode === 'active') setPageIndex(targetIndex);
122 setPages(prev => {
123 const next = [...prev];
124 next[targetIndex] = { records: mapped, cursor: nextCursor };
125 return next;
126 });
127 return nextCursor;
128 } catch (e) {
129 if (mode === 'active') setError(e as Error);
130 } finally {
131 if (mode === 'active') setLoading(false);
132 inFlight.current.delete(key);
133 }
134 return undefined;
135 }, [did, endpoint, collection, limit]);
136
137 useEffect(() => {
138 if (!handleOrDid) {
139 resetState();
140 setLoading(false);
141 setError(undefined);
142 return;
143 }
144
145 if (didError) {
146 resetState();
147 setLoading(false);
148 setError(didError);
149 return;
150 }
151
152 if (endpointError) {
153 resetState();
154 setLoading(false);
155 setError(endpointError);
156 return;
157 }
158
159 if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
160 resetState();
161 setLoading(true);
162 setError(undefined);
163 return;
164 }
165
166 resetState();
167 fetchPage(undefined, 0, 'active').catch(() => {
168 /* error handled in state */
169 });
170 }, [handleOrDid, did, endpoint, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
171
172 const currentPage = pages[pageIndex];
173 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
174 const hasPrev = pageIndex > 0;
175
176 const loadNext = useCallback(() => {
177 const page = pages[pageIndex];
178 if (!page?.cursor && !pages[pageIndex + 1]) return;
179 if (pages[pageIndex + 1]) {
180 setPageIndex(pageIndex + 1);
181 return;
182 }
183 fetchPage(page.cursor, pageIndex + 1, 'active').catch(() => {
184 /* handled via error state */
185 });
186 }, [fetchPage, pageIndex, pages]);
187
188 const loadPrev = useCallback(() => {
189 if (pageIndex === 0) return;
190 setPageIndex(pageIndex - 1);
191 }, [pageIndex]);
192
193 const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
194
195 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined);
196
197 useEffect(() => {
198 const cursor = pages[pageIndex]?.cursor;
199 if (!cursor) return;
200 if (pages[pageIndex + 1]) return;
201 fetchPage(cursor, pageIndex + 1, 'prefetch').catch(() => {
202 /* ignore prefetch errors */
203 });
204 }, [fetchPage, pageIndex, pages]);
205
206 return {
207 records,
208 loading,
209 error: effectiveError,
210 hasNext,
211 hasPrev,
212 loadNext,
213 loadPrev,
214 pageIndex,
215 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0)
216 };
217}
218
219function extractRkey(uri: string): string {
220 const parts = uri.split('/');
221 return parts[parts.length - 1];
222}