A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useState } from 'react';
2import { useDidResolution } from './useDidResolution';
3import { usePdsEndpoint } from './usePdsEndpoint';
4import { createAtprotoClient } from '../utils/atproto-client';
5
6/**
7 * Identifier trio required to address an AT Protocol record.
8 */
9export interface AtProtoRecordKey {
10 /** Repository DID (or handle prior to resolution) containing the record. */
11 did?: string;
12 /** NSID collection in which the record resides. */
13 collection?: string;
14 /** Record key string uniquely identifying the record within the collection. */
15 rkey?: string;
16}
17
18/**
19 * Loading state returned by {@link useAtProtoRecord}.
20 */
21export interface AtProtoRecordState<T = unknown> {
22 /** Resolved record value when fetch succeeds. */
23 record?: T;
24 /** Error thrown while loading, if any. */
25 error?: Error;
26 /** Indicates whether the hook is in a loading state. */
27 loading: boolean;
28}
29
30/**
31 * React hook that fetches a single AT Protocol record and tracks loading/error state.
32 *
33 * @param did - DID (or handle before resolution) that owns the record.
34 * @param collection - NSID collection from which to fetch the record.
35 * @param rkey - Record key identifying the record within the collection.
36 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
37 */
38export function useAtProtoRecord<T = unknown>({ did: handleOrDid, collection, rkey }: AtProtoRecordKey): AtProtoRecordState<T> {
39 const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
40 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
41 const [state, setState] = useState<AtProtoRecordState<T>>({ loading: !!(handleOrDid && collection && rkey) });
42
43 useEffect(() => {
44 let cancelled = false;
45
46 const assignState = (next: Partial<AtProtoRecordState<T>>) => {
47 if (cancelled) return;
48 setState(prev => ({ ...prev, ...next }));
49 };
50
51 if (!handleOrDid || !collection || !rkey) {
52 assignState({ loading: false, record: undefined, error: undefined });
53 return () => { cancelled = true; };
54 }
55
56 if (didError) {
57 assignState({ loading: false, error: didError });
58 return () => { cancelled = true; };
59 }
60
61 if (endpointError) {
62 assignState({ loading: false, error: endpointError });
63 return () => { cancelled = true; };
64 }
65
66 if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
67 assignState({ loading: true, error: undefined });
68 return () => { cancelled = true; };
69 }
70
71 assignState({ loading: true, error: undefined, record: undefined });
72
73 (async () => {
74 try {
75 const { rpc } = await createAtprotoClient({ service: endpoint });
76 const res = await (rpc as unknown as {
77 get: (
78 nsid: string,
79 opts: { params: { repo: string; collection: string; rkey: string } }
80 ) => Promise<{ ok: boolean; data: { value: T } }>;
81 }).get('com.atproto.repo.getRecord', {
82 params: { repo: did, collection, rkey }
83 });
84 if (!res.ok) throw new Error('Failed to load record');
85 const record = (res.data as { value: T }).value;
86 assignState({ record, loading: false });
87 } catch (e) {
88 const err = e instanceof Error ? e : new Error(String(e));
89 assignState({ error: err, loading: false });
90 }
91 })();
92
93 return () => {
94 cancelled = true;
95 };
96 }, [handleOrDid, did, endpoint, collection, rkey, resolvingDid, resolvingEndpoint, didError, endpointError]);
97
98 return state;
99}