Skip to main content

Rendering Asynchronous Data

Make your components reusable by binding the data where you use it with the one-line useSuspense(), which guarantees data like await.

import { Entity, createResource } from '@data-client/rest';

export class Post extends Entity {
  id = 0;
  userId = 0;
  title = '';
  body = '';

  pk() {
    return this.id?.toString();
  }
  static key = 'Post';
}
export const PostResource = createResource({
  path: '/posts/:id',
  schema: Post,
});

export class User extends Entity {
  id = 0;
  name = '';
  username = '';
  email = '';
  phone = '';
  website = '';

  get profileImage() {
    return `https://i.pravatar.cc/64?img=${this.id + 4}`;
  }

  pk() {
    return `${this.id}`;
  }
  static key = 'User';
}
export const UserResource = createResource({
  urlPrefix: 'https://jsonplaceholder.typicode.com',
  path: '/users/:id',
  schema: User,
});
import { useSuspense } from '@data-client/react';
import { UserResource, PostResource } from './Resources';

export default function PostDetail({ setRoute, id }) {
  const post = useSuspense(PostResource.get, { id });
  const author = useSuspense(UserResource.get, { id: post.userId });
  return (
    <div>
      <header>
        <div className="listItem spaced">
          <div className="author">
            <Avatar src={author.profileImage} />
            <small>{author.name}</small>
          </div>
          <h4>{post.title}</h4>
        </div>
      </header>
      <p>{post.body}</p>
      <a
        href="#"
        onClick={e => {
          e.preventDefault();
          setRoute('list');
        }}
      >
        « Back
      </a>
    </div>
  );
}
import { useSuspense } from '@data-client/react';
import { UserResource, type Post } from './Resources';

export default function PostItem({ post, setRoute }: Props) {
  const author = useSuspense(UserResource.get, { id: post.userId });
  return (
    <div className="listItem spaced">
      <Avatar src={author.profileImage} />
      <div>
        <h4>
          <a
            href="#"
            onClick={e => {
              e.preventDefault();
              setRoute(`detail/${post.id}`);
            }}
          >
            {post.title}
          </a>
        </h4>
        <small>by {author.name}</small>
      </div>
    </div>
  );
}

interface Props {
  post: Post;
  setRoute: Function;
}
import { useSuspense } from '@data-client/react';
import PostItem from './PostItem';
import { PostResource } from './Resources';

export default function PostList({ setRoute }) {
  const posts = useSuspense(PostResource.getList);
  return (
    <div>
      {posts.map(post => (
        <PostItem key={post.pk()} post={post} setRoute={setRoute} />
      ))}
    </div>
  );
}
import PostList from './PostList';
import PostDetail from './PostDetail';

function Navigation() {
  const [route, setRoute] = React.useState('list');
  if (route.startsWith('detail'))
    return (
      <PostDetail setRoute={setRoute} id={route.split('/')[1]} />
    );

  return <PostList setRoute={setRoute} />;
}
render(<Navigation />);
🔴 Live Preview
Store
Endpoints used in many contextsEndpoints used in many contexts

Do not prop drill. Instead, useSuspense() in the components that render the data from it. This is known as data co-location.

Instead of writing complex update functions or invalidations cascades, Reactive Data Client automatically updates bound components immediately upon data change. This is known as reactive programming.

Loading and Error

You might have noticed the return type shows the value is always there. useSuspense() operates very much like await. This enables us to make error/loading disjoint from data usage.

Async Boundaries

Instead we place <AsyncBoundary /> to handling loading and error conditions at or above navigational boundaries like pages, routes, or modals.

import { AsyncBoundary } from '@data-client/react';

export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<AsyncBoundary>
<section>
<Outlet />
</section>
</AsyncBoundary>
</div>
);
}

React 18's useTransition and Server Side Rendering powered routers or navigation means never seeing a loading fallback again. In React 16 and 17 fallbacks can be centralized to eliminate redundant loading indicators while keeping components reusable.

<AsyncBoundary /> also allows Server Side Rendering to incrementally stream HTML, greatly reducing TTFB. Reactive Data Client SSR's automatic store hydration means immediate user interactivity with zero client-side fetches on first load.

AsyncBoundary's error fallback and loading fallback can both be customized.

Stateful

You may find cases where it's still useful to use a stateful approach to fallbacks when using React 16 and 17. For these cases, or compatibility with some component libraries, useDLE() - [D]ata [L]oading [E]rror - is provided.

import { Entity, createResource } from '@data-client/rest';

export class Profile extends Entity {
  id: number | undefined = undefined;
  avatar = '';
  fullName = '';
  bio = '';

  pk() {
    return this.id?.toString();
  }
  static key = 'Profile';
}

export const ProfileResource = createResource({
  path: '/profiles/:id',
  schema: Profile,
});
import { useDLE } from '@data-client/react';
import { ProfileResource } from './ProfileResource';

function ProfileList(): JSX.Element {
  const { data, loading, error } = useDLE(ProfileResource.getList);
  if (error) return <div>Error {`${error.status}`}</div>;
  if (loading || !data) return <>loading...</>;
  return (
    <div>
      {data.map(profile => (
        <div className="listItem" key={profile.pk()}>
          <Avatar src={profile.avatar} />
          <div>
            <h4>{profile.fullName}</h4>
            <p>{profile.bio}</p>
          </div>
        </div>
      ))}
    </div>
  );
}
render(<ProfileList />);
🔴 Live Preview
Store

Since useDLE does not useSuspense, you won't be able to easily centrally orchestrate loading and error code. Additionally, React 18 features like useTransition, and incrementally streaming SSR won't work with components that use it.

Conditional

Conditional Dependencies

Use null as the second argument to any Data Client hook means "do nothing."

// todo could be undefined if id is undefined
const todo = useSuspense(TodoResource.get, id ? { id } : null);

Subscriptions

When data is likely to change due to external factor; useSubscription() ensures continual updates while a component is mounted. useLive() calls both useSubscription() and useSuspense(), making it quite easy to use fresh data.

import { Entity, RestEndpoint } from '@data-client/rest';

export class Ticker extends Entity {
  product_id = '';
  trade_id = 0;
  price = 0;
  size = '0';
  time = Temporal.Instant.fromEpochSeconds(0);
  bid = '0';
  ask = '0';
  volume = '';

  pk(): string {
    return this.product_id;
  }
  static key = 'Ticker';

  static schema = {
    price: Number,
    time: Temporal.Instant.from,
  };
}

export const getTicker = new RestEndpoint({
  urlPrefix: 'https://api.exchange.coinbase.com',
  path: '/products/:productId/ticker',
  schema: Ticker,
  process(value, { productId }) {
    value.product_id = productId;
    return value;
  },
  pollFrequency: 2000,
});
import { useLive } from '@data-client/react';
import { getTicker } from './Ticker';

function AssetPrice({ productId }: Props) {
  const ticker = useLive(getTicker, { productId });
  return (
    <center>
      {productId}{' '}
      <Formatted value={ticker.price} formatter="currency" />
    </center>
  );
}
interface Props {
  productId: string;
}
render(<AssetPrice productId="BTC-USD" />);
🔴 Live Preview
Store

Subscriptions are orchestrated by Managers. Out of the box, polling based subscriptions can be used by adding pollFrequency to an Endpoint or Resource. For pushed based networking protocols like SSE and websockets, see the example stream manager.

export const getTicker = new RestEndpoint({
urlPrefix: 'https://api.exchange.coinbase.com',
path: '/products/:productId/ticker',
schema: Ticker,
pollFrequency: 2000,
});