-
Notifications
You must be signed in to change notification settings - Fork 27
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 animationLoading 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>
);
}