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";
5import { useBlueskyAppview } from "./useBlueskyAppview";
6
7/**
8 * Identifier trio required to address an AT Protocol record.
9 */
10export interface AtProtoRecordKey {
11 /** Repository DID (or handle prior to resolution) containing the record. */
12 did?: string;
13 /** NSID collection in which the record resides. */
14 collection?: string;
15 /** Record key string uniquely identifying the record within the collection. */
16 rkey?: string;
17}
18
19/**
20 * Loading state returned by {@link useAtProtoRecord}.
21 */
22export interface AtProtoRecordState<T = unknown> {
23 /** Resolved record value when fetch succeeds. */
24 record?: T;
25 /** Error thrown while loading, if any. */
26 error?: Error;
27 /** Indicates whether the hook is in a loading state. */
28 loading: boolean;
29}
30
31/**
32 * React hook that fetches a single AT Protocol record and tracks loading/error state.
33 *
34 * For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy:
35 * 1. Try Bluesky appview API first
36 * 2. Fall back to Slingshot getRecord
37 * 3. Finally query the PDS directly
38 *
39 * For other collections, queries the PDS directly (with Slingshot fallback via the client handler).
40 *
41 * @param did - DID (or handle before resolution) that owns the record.
42 * @param collection - NSID collection from which to fetch the record.
43 * @param rkey - Record key identifying the record within the collection.
44 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
45 */
46export function useAtProtoRecord<T = unknown>({
47 did: handleOrDid,
48 collection,
49 rkey,
50}: AtProtoRecordKey): AtProtoRecordState<T> {
51 const isBlueskyCollection = collection?.startsWith("app.bsky.");
52
53 // Always call all hooks (React rules) - conditionally use results
54 const blueskyResult = useBlueskyAppview<T>({
55 did: isBlueskyCollection ? handleOrDid : undefined,
56 collection: isBlueskyCollection ? collection : undefined,
57 rkey: isBlueskyCollection ? rkey : undefined,
58 });
59
60 const {
61 did,
62 error: didError,
63 loading: resolvingDid,
64 } = useDidResolution(handleOrDid);
65 const {
66 endpoint,
67 error: endpointError,
68 loading: resolvingEndpoint,
69 } = usePdsEndpoint(did);
70 const [state, setState] = useState<AtProtoRecordState<T>>({
71 loading: !!(handleOrDid && collection && rkey),
72 });
73
74 useEffect(() => {
75 let cancelled = false;
76
77 const assignState = (next: Partial<AtProtoRecordState<T>>) => {
78 if (cancelled) return;
79 setState((prev) => ({ ...prev, ...next }));
80 };
81
82 if (!handleOrDid || !collection || !rkey) {
83 assignState({
84 loading: false,
85 record: undefined,
86 error: undefined,
87 });
88 return () => {
89 cancelled = true;
90 };
91 }
92
93 if (didError) {
94 assignState({ loading: false, error: didError });
95 return () => {
96 cancelled = true;
97 };
98 }
99
100 if (endpointError) {
101 assignState({ loading: false, error: endpointError });
102 return () => {
103 cancelled = true;
104 };
105 }
106
107 if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
108 assignState({ loading: true, error: undefined });
109 return () => {
110 cancelled = true;
111 };
112 }
113
114 assignState({ loading: true, error: undefined, record: undefined });
115
116 (async () => {
117 try {
118 const { rpc } = await createAtprotoClient({
119 service: endpoint,
120 });
121 const res = await (
122 rpc as unknown as {
123 get: (
124 nsid: string,
125 opts: {
126 params: {
127 repo: string;
128 collection: string;
129 rkey: string;
130 };
131 },
132 ) => Promise<{ ok: boolean; data: { value: T } }>;
133 }
134 ).get("com.atproto.repo.getRecord", {
135 params: { repo: did, collection, rkey },
136 });
137 if (!res.ok) throw new Error("Failed to load record");
138 const record = (res.data as { value: T }).value;
139 assignState({ record, loading: false });
140 } catch (e) {
141 const err = e instanceof Error ? e : new Error(String(e));
142 assignState({ error: err, loading: false });
143 }
144 })();
145
146 return () => {
147 cancelled = true;
148 };
149 }, [
150 handleOrDid,
151 did,
152 endpoint,
153 collection,
154 rkey,
155 resolvingDid,
156 resolvingEndpoint,
157 didError,
158 endpointError,
159 ]);
160
161 // Return Bluesky result for app.bsky.* collections
162 if (isBlueskyCollection) {
163 return {
164 record: blueskyResult.record,
165 error: blueskyResult.error,
166 loading: blueskyResult.loading,
167 };
168 }
169
170 return state;
171}