Skip to content

RSC Utilities

Cindy Zhang edited this page Jun 23, 2026 · 1 revision

RSC Utilities (Future)

⚠️ Status: Not implemented. The components described below — Lazy, Stream, and IntersectionTrigger — are proposed designs only. None of them exist in Astryx today. Only Skeleton and Spinner are implemented and available for use. This page captures the design intent for future RSC integration work.

Overview

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

Implemented Components

Skeleton

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

Spinner

Loading spinner for in-progress states:

<Spinner />
<Spinner size="small" />
<Spinner size="large" />

Proposed Components

Everything below this line is a design proposal. APIs may change significantly before implementation.

IntersectionTrigger

Fires a callback when element enters viewport. For client-side pagination with callbacks:

<IntersectionTrigger
  onIntersect={loadMore}
  rootMargin="200px"
  disabled={!hasMore}>
  {isLoading && <Spinner />}
</IntersectionTrigger>

Props

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
}

Implementation Sketch

'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

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>

Props

interface LazyProps<T> {
  fetch: () => Promise<T>; // Data fetcher
  fallback: ReactNode; // Loading placeholder
  children: (data: T) => ReactNode; // Render function
  rootMargin?: string; // Intersection margin (default: '100px')
}

Implementation Sketch

'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>;
}

Stream

Fetches RSC flight response on intersection and renders server components. For React Server Component pagination:

<Stream
  endpoint="/api/users"
  params={{cursor: nextCursor}}
  loading={<Spinner />}
/>

Props

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
}

Implementation Sketch

'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>;
}

Recursive Pattern

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:

  1. New content
  2. Next Stream sentinel (if more data exists)

This creates infinite scroll without client-side state management.

Considerations

  • 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

Usage Examples

List with Infinite 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>
  );
}

Card Grid with Lazy Images

<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>

Client-Side Pagination

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>
  );
}

Clone this wiki locally