-
Notifications
You must be signed in to change notification settings - Fork 1.1k
RSC Utilities
⚠️ Status: Not implemented. The components described below —Lazy,Stream, andIntersectionTrigger— are proposed designs only. None of them exist in Astryx today. OnlySkeletonandSpinnerare implemented and available for use. This page captures the design intent for future RSC integration work.
| Component | Fetches | Renders | Use Case | Status |
|---|---|---|---|---|
Skeleton |
— | Shimmer placeholder | Loading states | ✅ Implemented |
Spinner |
— | Spinning indicator | In-progress states | ✅ Implemented |
IntersectionTrigger |
— | Fires callback | Custom intersection logic | 🔮 Proposed |
Lazy |
Data (JSON) | Client-side | Lazy cell/field values | 🔮 Proposed |
Stream |
RSC flight | Server components | Infinite scroll, pagination | 🔮 Proposed |
Loading placeholder with shimmer animation:
<Skeleton /> // Full width <Skeleton width={100} />// Fixed width <Skeleton width="50%" /> // Percentage <Skeleton height={20} /> // Custom height <Skeleton shape="circle" /> // Avatar placeholder <Skeleton animate={false} /> // No animation
Loading spinner for in-progress states:
<Spinner /> <Spinner size="small" /> <Spinner size="large" />
Everything below this line is a design proposal. APIs may change significantly before implementation.
Fires a callback when element enters viewport. For client-side pagination with callbacks:
<IntersectionTrigger onIntersect={loadMore} rootMargin="200px" disabled={!hasMore}> {isLoading && <Spinner />} </IntersectionTrigger>
interface IntersectionTriggerProps { onIntersect: () => void; // Callback when visible rootMargin?: string; // Intersection margin (default: '100px') threshold?: number; // Visibility threshold (default: 0) disabled?: boolean; // Disable observation children?: ReactNode; // Content to render }
'use client'; function IntersectionTrigger({ onIntersect, rootMargin = '100px', threshold = 0, disabled = false, children, }) { const ref = useRef<HTMLDivElement>(null); useEffect(() => { if (disabled || !ref.current) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { onIntersect(); } }, {rootMargin, threshold}, ); observer.observe(ref.current); return () => observer.disconnect(); }, [disabled, onIntersect, rootMargin, threshold]); return <div ref={ref}>{children}</div>; }
Lazy load wrapper that fetches data on intersection and renders client-side:
<Lazy fetch={() => fetchScore(user.id)} fallback={<Skeleton width={60} />}> {score => <span>{score}</span>} </Lazy>
interface LazyProps<T> { fetch: () => Promise<T>; // Data fetcher fallback: ReactNode; // Loading placeholder children: (data: T) => ReactNode; // Render function rootMargin?: string; // Intersection margin (default: '100px') }
'use client'; function Lazy<T>({ fetch, fallback, children, rootMargin = '100px', }: LazyProps<T>) { const ref = useRef<HTMLDivElement>(null); const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(false); const [triggered, setTriggered] = useState(false); useEffect(() => { if (triggered || !ref.current) return; const observer = new IntersectionObserver( async ([entry]) => { if (entry.isIntersecting) { setTriggered(true); setLoading(true); observer.disconnect(); try { const result = await fetch(); setData(result); } finally { setLoading(false); } } }, {rootMargin}, ); observer.observe(ref.current); return () => observer.disconnect(); }, [triggered, fetch, rootMargin]); if (data !== null) { return <>{children(data)}</>; } return <div ref={ref}>{loading || triggered ? fallback : null}</div>; }
Fetches RSC flight response on intersection and renders server components. For React Server Component pagination:
<Stream endpoint="/api/users" params={{cursor: nextCursor}} loading={<Spinner />} />
interface StreamProps { endpoint: string; // RSC endpoint URL params?: Record<string, string>; // Query params (cursor, limit, etc.) rootMargin?: string; // Intersection margin (default: '200px') loading?: ReactNode; // Loading indicator disabled?: boolean; // Disable fetching }
'use client'; function Stream({ endpoint, params, rootMargin = '200px', loading, disabled = false, }) { const ref = useRef<HTMLDivElement>(null); const [content, setContent] = useState<ReactNode>(null); const [fetching, setFetching] = useState(false); useEffect(() => { if (disabled || fetching || content) return; const observer = new IntersectionObserver( async ([entry]) => { if (entry.isIntersecting) { setFetching(true); observer.disconnect(); const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint; const response = await fetch(url, { headers: {RSC: '1'}, }); // Framework-specific flight response handling const serverContent = await createFromReadableStream(response.body); setContent(serverContent); setFetching(false); } }, {rootMargin}, ); if (ref.current) observer.observe(ref.current); return () => observer.disconnect(); }, [disabled, fetching, content, endpoint, params, rootMargin]); // Already fetched - render server content if (content) return <>{content}</>; // Disabled - render nothing if (disabled) return null; // Sentinel + loading state return <div ref={ref}>{fetching && loading}</div>; }
The server endpoint returns content + next trigger, creating a chain:
// /api/users endpoint (server) async function UsersEndpoint({searchParams}) { const cursor = searchParams.cursor; const {items, nextCursor, hasMore} = await fetchUsers({ after: cursor, limit: 20, }); return ( <> {items.map(item => ( <Item key={item.id} data={item} /> ))} {hasMore && ( <Stream endpoint="/api/users" params={{cursor: nextCursor}} /> )} </> ); }
Each fetch returns:
- New content
- Next
Streamsentinel (if more data exists)
This creates infinite scroll without client-side state management.
- Framework coupling: Flight response parsing varies (Next.js, Waku, etc.)
- Error handling: Consider adding error boundary or error state
- Retry: Could add retry logic on fetch failure
- Deduplication: Prevent double-fetches on rapid scroll
async function UserListPage() { const {users, nextCursor, hasMore} = await fetchUsers({limit: 20}); return ( <List> {users.map(user => ( <ListItem key={user.id}>{user.name}</ListItem> ))} {hasMore && ( <Stream endpoint="/api/users" params={{cursor: nextCursor}} loading={<Spinner />} /> )} </List> ); }
<div className="grid"> {items.map(item => ( <Card key={item.id}> <Lazy fetch={() => fetchThumbnail(item.id)} fallback={<Skeleton shape="rectangle" height={200} />}> {src => <img src={src} alt={item.title} />} </Lazy> <h3>{item.title}</h3> </Card> ))} </div>
function ClientPaginatedList() { const [items, setItems] = useState(initialItems); const [cursor, setCursor] = useState(initialCursor); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); const loadMore = async () => { setLoading(true); const {items: newItems, nextCursor} = await fetchItems({after: cursor}); setItems(prev => [...prev, ...newItems]); setCursor(nextCursor); setHasMore(!!nextCursor); setLoading(false); }; return ( <ul> {items.map(item => ( <li key={item.id}>{item.name}</li> ))} <IntersectionTrigger onIntersect={loadMore} disabled={!hasMore || loading}> {loading && <Spinner />} </IntersectionTrigger> </ul> ); }
Start here Astryx Philosophy Contributing with AI Assistants Contributing
Architecture System Architecture Architecture Cheat Sheet Theming Infrastructure Distribution
Building a component Component Lifecycle Component Authoring Guide API Conventions Design Conventions
Quality Component Audit Rubric Accessibility Checklist AST-009 assistive-technology verification
Operations Release Process Night Watch Overview