A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1# AtReact Hooks Deep Dive
2
3## Overview
4The AtReact hooks system provides a robust, cache-optimized layer for fetching AT Protocol data. All hooks follow React best practices with proper cleanup, cancellation, and stable references.
5
6---
7
8## Core Architecture Principles
9
10### 1. **Three-Tier Caching Strategy**
11All data flows through three cache layers:
12- **DidCache** - DID documents, handle mappings, PDS endpoints
13- **BlobCache** - Media/image blobs with reference counting
14- **RecordCache** - AT Protocol records with deduplication
15
16### 2. **Concurrent Request Deduplication**
17When multiple components request the same data, only one network request is made. Uses reference counting to manage in-flight requests.
18
19### 3. **Stable Reference Pattern**
20Caches use memoized snapshots to prevent unnecessary re-renders:
21```typescript
22// Only creates new snapshot if data actually changed
23if (existing && existing.did === did && existing.handle === handle) {
24 return toSnapshot(existing); // Reuse existing
25}
26```
27
28### 4. **Three-Tier Fallback for Bluesky**
29For `app.bsky.*` collections:
301. Try Bluesky appview API (fastest, public)
312. Fall back to Slingshot (microcosm service)
323. Finally query PDS directly
33
34---
35
36## Hook Catalog
37
38## 1. `useDidResolution`
39**Purpose:** Resolves handles to DIDs or fetches DID documents
40
41### Key Features:
42- **Bidirectional:** Works with handles OR DIDs
43- **Smart Caching:** Only fetches if not in cache
44- **Dual Resolution Paths:**
45 - Handle → DID: Uses Slingshot first, then appview
46 - DID → Document: Fetches full DID document for handle extraction
47
48### State Flow:
49```typescript
50Input: "alice.bsky.social" or "did:plc:xxx"
51 ↓
52Check didCache
53 ↓
54If handle: ensureHandle(resolver, handle) → DID
55If DID: ensureDidDoc(resolver, did) → DID doc + handle from alsoKnownAs
56 ↓
57Return: { did, handle, loading, error }
58```
59
60### Critical Implementation Details:
61- **Normalizes input** to lowercase for handles
62- **Memoizes input** to prevent effect re-runs
63- **Stabilizes error references** - only updates if message changes
64- **Cleanup:** Cancellation token prevents stale updates
65
66---
67
68## 2. `usePdsEndpoint`
69**Purpose:** Discovers the PDS endpoint for a DID
70
71### Key Features:
72- **Depends on DID resolution** (implicit dependency)
73- **Extracts from DID document** if already cached
74- **Lazy fetching** - only when endpoint not in cache
75
76### State Flow:
77```typescript
78Input: DID
79 ↓
80Check didCache.getByDid(did).pdsEndpoint
81 ↓
82If missing: ensurePdsEndpoint(resolver, did)
83 ├─ Tries to get from existing DID doc
84 └─ Falls back to resolver.pdsEndpointForDid()
85 ↓
86Return: { endpoint, loading, error }
87```
88
89### Service Discovery:
90Looks for `AtprotoPersonalDataServer` service in DID document:
91```json
92{
93 "service": [{
94 "type": "AtprotoPersonalDataServer",
95 "serviceEndpoint": "https://pds.example.com"
96 }]
97}
98```
99
100---
101
102## 3. `useAtProtoRecord`
103**Purpose:** Fetches a single AT Protocol record with smart routing
104
105### Key Features:
106- **Collection-aware routing:** Bluesky vs other protocols
107- **RecordCache deduplication:** Multiple components = one fetch
108- **Cleanup with reference counting**
109
110### State Flow:
111```typescript
112Input: { did, collection, rkey }
113 ↓
114If collection.startsWith("app.bsky."):
115 └─ useBlueskyAppview() → Three-tier fallback
116Else:
117 ├─ useDidResolution(did)
118 ├─ usePdsEndpoint(resolved.did)
119 └─ recordCache.ensure() → Fetch from PDS
120 ↓
121Return: { record, loading, error }
122```
123
124### RecordCache Deduplication:
125```typescript
126// First component calling this
127const { promise, release } = recordCache.ensure(did, collection, rkey, loader)
128// refCount = 1
129
130// Second component calling same record
131const { promise, release } = recordCache.ensure(...) // Same promise!
132// refCount = 2
133
134// On cleanup, both call release()
135// Only aborts when refCount reaches 0
136```
137
138---
139
140## 4. `useBlueskyAppview`
141**Purpose:** Fetches Bluesky records with appview optimization
142
143### Key Features:
144- **Collection-aware endpoints:**
145 - `app.bsky.actor.profile` → `app.bsky.actor.getProfile`
146 - `app.bsky.feed.post` → `app.bsky.feed.getPostThread`
147- **CDN URL extraction:** Parses CDN URLs to extract CIDs
148- **Atomic state updates:** Uses reducer for complex state
149
150### Three-Tier Fallback with Source Tracking:
151```typescript
152async function fetchWithFallback() {
153 // Tier 1: Appview (if endpoint mapped)
154 try {
155 const result = await fetchFromAppview(did, collection, rkey);
156 return { record: result, source: "appview" };
157 } catch {}
158
159 // Tier 2: Slingshot
160 try {
161 const result = await fetchFromSlingshot(did, collection, rkey);
162 return { record: result, source: "slingshot" };
163 } catch {}
164
165 // Tier 3: PDS
166 try {
167 const result = await fetchFromPds(did, collection, rkey);
168 return { record: result, source: "pds" };
169 } catch {}
170
171 // All tiers failed - provide helpful error for banned Bluesky accounts
172 if (pdsEndpoint.includes('.bsky.network')) {
173 throw new Error('Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.');
174 }
175
176 throw new Error('Failed to fetch record from all sources');
177}
178```
179
180The `source` field in the result accurately indicates which tier successfully fetched the data, enabling debugging and analytics.
181
182### CDN URL Handling:
183Appview returns CDN URLs like:
184```
185https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
186```
187
188Hook extracts CID (`bafkreixxx`) and creates standard Blob object:
189```typescript
190{
191 $type: "blob",
192 ref: { $link: "bafkreixxx" },
193 mimeType: "image/jpeg",
194 size: 0,
195 cdnUrl: "https://cdn.bsky.app/..." // Preserved for fast rendering
196}
197```
198
199### Reducer Pattern:
200```typescript
201type Action =
202 | { type: "SET_LOADING"; loading: boolean }
203 | { type: "SET_SUCCESS"; record: T; source: "appview" | "slingshot" | "pds" }
204 | { type: "SET_ERROR"; error: Error }
205 | { type: "RESET" };
206
207// Atomic state updates, no race conditions
208dispatch({ type: "SET_SUCCESS", record, source });
209```
210
211---
212
213## 5. `useLatestRecord`
214**Purpose:** Fetches the most recent record from a collection
215
216### Key Features:
217- **Timestamp validation:** Skips records before 2023 (pre-ATProto)
218- **PDS-only:** Slingshot doesn't support `listRecords`
219- **Smart fetching:** Gets 3 records to handle invalid timestamps
220
221### State Flow:
222```typescript
223Input: { did, collection }
224 ↓
225useDidResolution(did)
226usePdsEndpoint(did)
227 ↓
228callListRecords(endpoint, did, collection, limit: 3)
229 ↓
230Filter: isValidTimestamp(record) → year >= 2023
231 ↓
232Return first valid record: { record, rkey, loading, error, empty }
233```
234
235### Timestamp Validation:
236```typescript
237function isValidTimestamp(record: unknown): boolean {
238 const timestamp = record.createdAt || record.indexedAt;
239 if (!timestamp) return true; // No timestamp, assume valid
240
241 const date = new Date(timestamp);
242 return date.getFullYear() >= 2023; // ATProto created in 2023
243}
244```
245
246---
247
248## 6. `usePaginatedRecords`
249**Purpose:** Cursor-based pagination with prefetching
250
251### Key Features:
252- **Dual fetching modes:**
253 - Author feed (appview) - for Bluesky posts with filters
254 - Direct PDS - for all other collections
255- **Smart prefetching:** Loads next page in background
256- **Invalid timestamp filtering:** Same as `useLatestRecord`
257- **Request sequencing:** Prevents race conditions with `requestSeq`
258
259### State Management:
260```typescript
261// Pages stored as array
262pages: [
263 { records: [...], cursor: "abc" }, // page 0
264 { records: [...], cursor: "def" }, // page 1
265 { records: [...], cursor: undefined } // page 2 (last)
266]
267pageIndex: 1 // Currently viewing page 1
268```
269
270### Prefetch Logic:
271```typescript
272useEffect(() => {
273 const cursor = pages[pageIndex]?.cursor;
274 if (!cursor || pages[pageIndex + 1]) return; // No cursor or already loaded
275
276 // Prefetch next page in background
277 fetchPage(identity, cursor, pageIndex + 1, "prefetch");
278}, [pageIndex, pages]);
279```
280
281### Author Feed vs PDS:
282```typescript
283if (preferAuthorFeed && collection === "app.bsky.feed.post") {
284 // Use app.bsky.feed.getAuthorFeed
285 const res = await callAppviewRpc("app.bsky.feed.getAuthorFeed", {
286 actor: handle || did,
287 filter: "posts_with_media", // Optional filter
288 includePins: true
289 });
290} else {
291 // Use com.atproto.repo.listRecords
292 const res = await callListRecords(pdsEndpoint, did, collection, limit);
293}
294```
295
296### Race Condition Prevention:
297```typescript
298const requestSeq = useRef(0);
299
300// On identity change
301resetState();
302requestSeq.current += 1; // Invalidate in-flight requests
303
304// In fetch callback
305const token = requestSeq.current;
306// ... do async work ...
307if (token !== requestSeq.current) return; // Stale request, abort
308```
309
310---
311
312## 7. `useBlob`
313**Purpose:** Fetches and caches media blobs with object URL management
314
315### Key Features:
316- **Automatic cleanup:** Revokes object URLs on unmount
317- **BlobCache deduplication:** Same blob = one fetch
318- **Reference counting:** Safe concurrent access
319
320### State Flow:
321```typescript
322Input: { did, cid }
323 ↓
324useDidResolution(did)
325usePdsEndpoint(did)
326 ↓
327Check blobCache.get(did, cid)
328 ↓
329If missing: blobCache.ensure() → Fetch from PDS
330 ├─ GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}
331 └─ Store in cache
332 ↓
333Create object URL: URL.createObjectURL(blob)
334 ↓
335Return: { url, loading, error }
336 ↓
337Cleanup: URL.revokeObjectURL(url)
338```
339
340### Object URL Management:
341```typescript
342const objectUrlRef = useRef<string>();
343
344// On successful fetch
345const nextUrl = URL.createObjectURL(blob);
346const prevUrl = objectUrlRef.current;
347objectUrlRef.current = nextUrl;
348if (prevUrl) URL.revokeObjectURL(prevUrl); // Clean up old URL
349
350// On unmount
351useEffect(() => () => {
352 if (objectUrlRef.current) {
353 URL.revokeObjectURL(objectUrlRef.current);
354 }
355}, []);
356```
357
358---
359
360## 8. `useBlueskyProfile`
361**Purpose:** Wrapper around `useBlueskyAppview` for profile records
362
363### Key Features:
364- **Simplified interface:** Just pass DID
365- **Type conversion:** Converts ProfileRecord to BlueskyProfileData
366- **CID extraction:** Extracts avatar/banner CIDs from blobs
367
368### Implementation:
369```typescript
370export function useBlueskyProfile(did: string | undefined) {
371 const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
372 did,
373 collection: "app.bsky.actor.profile",
374 rkey: "self",
375 });
376
377 const data = record ? {
378 did: did || "",
379 handle: "", // Populated by caller
380 displayName: record.displayName,
381 description: record.description,
382 avatar: extractCidFromBlob(record.avatar),
383 banner: extractCidFromBlob(record.banner),
384 createdAt: record.createdAt,
385 } : undefined;
386
387 return { data, loading, error };
388}
389```
390
391---
392
393## 9. `useBacklinks`
394**Purpose:** Fetches backlinks from Microcosm Constellation API
395
396### Key Features:
397- **Specialized use case:** Tangled stars, etc.
398- **Abort controller:** Cancels in-flight requests
399- **Refetch support:** Manual refresh capability
400
401### State Flow:
402```typescript
403Input: { subject: "at://did:plc:xxx/sh.tangled.repo/yyy", source: "sh.tangled.feed.star:subject" }
404 ↓
405GET https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks
406 ?subject={subject}&source={source}&limit={limit}
407 ↓
408Return: { backlinks: [...], total, loading, error, refetch }
409```
410
411---
412
413## 10. `useRepoLanguages`
414**Purpose:** Fetches language statistics from Tangled knot server
415
416### Key Features:
417- **Branch fallback:** Tries "main", then "master"
418- **Knot server query:** For repository analysis
419
420### State Flow:
421```typescript
422Input: { knot: "knot.gaze.systems", did, repoName, branch }
423 ↓
424GET https://{knot}/xrpc/sh.tangled.repo.languages
425 ?repo={did}/{repoName}&ref={branch}
426 ↓
427If 404: Try fallback branch
428 ↓
429Return: { data: { languages: {...} }, loading, error }
430```
431
432---
433
434## Cache Implementation Deep Dive
435
436### DidCache
437**Purpose:** Cache DID documents, handle mappings, PDS endpoints
438
439```typescript
440class DidCache {
441 private byHandle = new Map<string, DidCacheEntry>();
442 private byDid = new Map<string, DidCacheEntry>();
443 private handlePromises = new Map<string, Promise<...>>();
444 private docPromises = new Map<string, Promise<...>>();
445 private pdsPromises = new Map<string, Promise<...>>();
446
447 // Memoized snapshots prevent re-renders
448 private toSnapshot(entry): DidCacheSnapshot {
449 if (entry.snapshot) return entry.snapshot; // Reuse
450 entry.snapshot = { did, handle, doc, pdsEndpoint };
451 return entry.snapshot;
452 }
453}
454```
455
456**Key methods:**
457- `getByHandle(handle)` - Instant cache lookup
458- `getByDid(did)` - Instant cache lookup
459- `ensureHandle(resolver, handle)` - Deduplicated resolution
460- `ensureDidDoc(resolver, did)` - Deduplicated doc fetch
461- `ensurePdsEndpoint(resolver, did)` - Deduplicated PDS discovery
462
463**Snapshot stability:**
464```typescript
465memoize(entry) {
466 const existing = this.byDid.get(did);
467
468 // Data unchanged? Reuse snapshot (same reference)
469 if (existing && existing.did === did &&
470 existing.handle === handle && ...) {
471 return toSnapshot(existing); // Prevents re-render!
472 }
473
474 // Data changed, create new entry
475 const merged = { did, handle, doc, pdsEndpoint, snapshot: undefined };
476 this.byDid.set(did, merged);
477 return toSnapshot(merged);
478}
479```
480
481### BlobCache
482**Purpose:** Cache media blobs with reference counting
483
484```typescript
485class BlobCache {
486 private store = new Map<string, BlobCacheEntry>();
487 private inFlight = new Map<string, InFlightBlobEntry>();
488
489 ensure(did, cid, loader) {
490 // Already cached?
491 const cached = this.get(did, cid);
492 if (cached) return { promise: Promise.resolve(cached), release: noop };
493
494 // In-flight request?
495 const existing = this.inFlight.get(key);
496 if (existing) {
497 existing.refCount++; // Multiple consumers
498 return { promise: existing.promise, release: () => this.release(key) };
499 }
500
501 // New request
502 const { promise, abort } = loader();
503 this.inFlight.set(key, { promise, abort, refCount: 1 });
504 return { promise, release: () => this.release(key) };
505 }
506
507 private release(key) {
508 const entry = this.inFlight.get(key);
509 entry.refCount--;
510 if (entry.refCount <= 0) {
511 this.inFlight.delete(key);
512 entry.abort(); // Cancel fetch
513 }
514 }
515}
516```
517
518### RecordCache
519**Purpose:** Cache AT Protocol records with deduplication
520
521Identical structure to BlobCache but for record data.
522
523---
524
525## Common Patterns
526
527### 1. Cancellation Pattern
528```typescript
529useEffect(() => {
530 let cancelled = false;
531
532 const assignState = (next) => {
533 if (cancelled) return; // Don't update unmounted component
534 setState(prev => ({ ...prev, ...next }));
535 };
536
537 // ... async work ...
538
539 return () => {
540 cancelled = true; // Mark as cancelled
541 release?.(); // Decrement refCount
542 };
543}, [deps]);
544```
545
546### 2. Error Stabilization Pattern
547```typescript
548setError(prevError =>
549 prevError?.message === newError.message
550 ? prevError // Reuse same reference
551 : newError // New error
552);
553```
554
555### 3. Identity Tracking Pattern
556```typescript
557const identityRef = useRef<string>();
558const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
559
560useEffect(() => {
561 if (identityRef.current !== identity) {
562 identityRef.current = identity;
563 resetState(); // Clear stale data
564 }
565 // ...
566}, [identity]);
567```
568
569### 4. Dual-Mode Resolution
570```typescript
571const isDid = input.startsWith("did:");
572const normalizedHandle = !isDid ? input.toLowerCase() : undefined;
573
574// Different code paths
575if (isDid) {
576 snapshot = await didCache.ensureDidDoc(resolver, input);
577} else {
578 snapshot = await didCache.ensureHandle(resolver, normalizedHandle);
579}
580```
581
582---
583
584## Performance Optimizations
585
586### 1. **Memoized Snapshots**
587Caches return stable references when data unchanged → prevents re-renders
588
589### 2. **Reference Counting**
590Multiple components requesting same data share one fetch
591
592### 3. **Prefetching**
593`usePaginatedRecords` loads next page in background
594
595### 4. **CDN URLs**
596Bluesky appview returns CDN URLs → skip blob fetching for images
597
598### 5. **Smart Routing**
599Bluesky collections use fast appview → non-Bluesky goes direct to PDS
600
601### 6. **Request Deduplication**
602In-flight request maps prevent duplicate fetches
603
604### 7. **Timestamp Validation**
605Skip invalid records early (before 2023) → fewer wasted cycles
606
607---
608
609## Error Handling Strategy
610
611### 1. **Fallback Chains**
612Never fail on first attempt → try multiple sources
613
614### 2. **Graceful Degradation**
615```typescript
616// Slingshot failed? Try appview
617try {
618 return await fetchFromSlingshot();
619} catch (slingshotError) {
620 try {
621 return await fetchFromAppview();
622 } catch (appviewError) {
623 // Combine errors for better debugging
624 throw new Error(`${appviewError.message}; Slingshot: ${slingshotError.message}`);
625 }
626}
627```
628
629### 3. **Component Isolation**
630Errors in one component don't crash others (via error boundaries recommended)
631
632### 4. **Abort Handling**
633```typescript
634try {
635 await fetch(url, { signal });
636} catch (err) {
637 if (err.name === "AbortError") return; // Expected, ignore
638 throw err;
639}
640```
641
642### 5. **Banned Bluesky Account Detection**
643When all three tiers fail and the PDS is a `.bsky.network` endpoint, provide a helpful error:
644```typescript
645// All tiers failed - check if it's a banned Bluesky account
646if (pdsEndpoint.includes('.bsky.network')) {
647 throw new Error(
648 'Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.'
649 );
650}
651```
652
653This helps users understand why data is unavailable instead of showing generic fetch errors. Applies to both `useBlueskyAppview` and `useAtProtoRecord` hooks.
654
655---
656
657## Testing Considerations
658
659### Key scenarios to test:
6601. **Concurrent requests:** Multiple components requesting same data
6612. **Race conditions:** Component unmounting mid-fetch
6623. **Cache invalidation:** Identity changes during fetch
6634. **Error fallbacks:** Slingshot down → appview works
6645. **Timestamp filtering:** Records before 2023 skipped
6656. **Reference counting:** Proper cleanup on unmount
6667. **Prefetching:** Background loads don't interfere with active loads
667
668---
669
670## Common Gotchas
671
672### 1. **React Rules of Hooks**
673All hooks called unconditionally, even if results not used:
674```typescript
675// Always call, conditionally use results
676const blueskyResult = useBlueskyAppview({
677 did: isBlueskyCollection ? handleOrDid : undefined, // Pass undefined to skip
678 collection: isBlueskyCollection ? collection : undefined,
679 rkey: isBlueskyCollection ? rkey : undefined,
680});
681```
682
683### 2. **Cleanup Order Matters**
684```typescript
685return () => {
686 cancelled = true; // 1. Prevent state updates
687 release?.(); // 2. Decrement refCount
688 revokeObjectURL(...); // 3. Free resources
689};
690```
691
692### 3. **Snapshot Reuse**
693Don't modify cached snapshots! They're shared across components.
694
695### 4. **CDN URL Extraction**
696Bluesky CDN URLs must be parsed carefully:
697```
698https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
699 ^^^^^^^^^^^^ ^^^^^^
700 DID CID
701```