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>({
39 did: handleOrDid,
40 collection,
41 rkey,
42}: AtProtoRecordKey): AtProtoRecordState<T> {
43 const {
44 did,
45 error: didError,
46 loading: resolvingDid,
47 } = useDidResolution(handleOrDid);
48 const {
49 endpoint,
50 error: endpointError,
51 loading: resolvingEndpoint,
52 } = usePdsEndpoint(did);
53 const [state, setState] = useState<AtProtoRecordState<T>>({
54 loading: !!(handleOrDid && collection && rkey),
55 });
56
57 useEffect(() => {
58 let cancelled = false;
59
60 const assignState = (next: Partial<AtProtoRecordState<T>>) => {
61 if (cancelled) return;
62 setState((prev) => ({ ...prev, ...next }));
63 };
64
65 if (!handleOrDid || !collection || !rkey) {
66 assignState({
67 loading: false,
68 record: undefined,
69 error: undefined,
70 });
71 return () => {
72 cancelled = true;
73 };
74 }
75
76 if (didError) {
77 assignState({ loading: false, error: didError });
78 return () => {
79 cancelled = true;
80 };
81 }
82
83 if (endpointError) {
84 assignState({ loading: false, error: endpointError });
85 return () => {
86 cancelled = true;
87 };
88 }
89
90 if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
91 assignState({ loading: true, error: undefined });
92 return () => {
93 cancelled = true;
94 };
95 }
96
97 assignState({ loading: true, error: undefined, record: undefined });
98
99 (async () => {
100 try {
101 const { rpc } = await createAtprotoClient({
102 service: endpoint,
103 });
104 const res = await (
105 rpc as unknown as {
106 get: (
107 nsid: string,
108 opts: {
109 params: {
110 repo: string;
111 collection: string;
112 rkey: string;
113 };
114 },
115 ) => Promise<{ ok: boolean; data: { value: T } }>;
116 }
117 ).get("com.atproto.repo.getRecord", {
118 params: { repo: did, collection, rkey },
119 });
120 if (!res.ok) throw new Error("Failed to load record");
121 const record = (res.data as { value: T }).value;
122 assignState({ record, loading: false });
123 } catch (e) {
124 const err = e instanceof Error ? e : new Error(String(e));
125 assignState({ error: err, loading: false });
126 }
127 })();
128
129 return () => {
130 cancelled = true;
131 };
132 }, [
133 handleOrDid,
134 did,
135 endpoint,
136 collection,
137 rkey,
138 resolvingDid,
139 resolvingEndpoint,
140 didError,
141 endpointError,
142 ]);
143
144 return state;
145}