Skip to main content

useSuspense()

High performance async data rendering without overfetching.

useSuspense() is like await for React components. This means the remainder of the component only runs after the data has loaded, avoiding the complexity of handling loading and error conditions. Instead, fallback handling is centralized with a singular AsyncBoundary.

useSuspense() is reactive to data mutations; rerendering only when necessary.

Usage

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 { useSuspense } from '@data-client/react';
import { ProfileResource } from './ProfileResource';

function ProfileDetail(): JSX.Element {
  const profile = useSuspense(ProfileResource.get, { id: 1 });
  return (
    <div className="listItem">
      <Avatar src={profile.avatar} />
      <div>
        <h4>{profile.fullName}</h4>
        <p>{profile.bio}</p>
      </div>
    </div>
  );
}
render(<ProfileDetail />);
🔴 Live Preview
Store

Behavior

Cache policy is Stale-While-Revalidate by default but also configurable.

Expiry StatusFetchSuspendErrorConditions
Invalidyes1yesnonot in store, deletion, invalidation, invalidIfStale
Staleyes1nono(first-render, arg change) & expiry < now
Validnonomaybe2fetch completion
nonononull used as second argument
note
  1. Identical fetches are automatically deduplicated
  2. Hard errors to be caught by Error Boundaries
React Native

When using React Navigation, useSuspense() will trigger fetches on focus if the data is considered stale.

Conditional Dependencies

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

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

Types

function useSuspense(
endpoint: ReadEndpoint,
...args: Parameters<typeof endpoint> | [null]
): Denormalize<typeof endpoint.schema>;

Examples

List

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 { useSuspense } from '@data-client/react';
import { ProfileResource } from './ProfileResource';

function ProfileList(): JSX.Element {
  const profiles = useSuspense(ProfileResource.getList);
  return (
    <div>
      {profiles.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

Sequential

When fetch parameters depend on data from another resource.

function PostWithAuthor() {
const post = useSuspense(PostResource.get, { id });
// post as Post
const author = useSuspense(UserResource.get, {
id: post.userId,
});
// author as User
}

Conditional

null will avoid binding and fetching data

Resources
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,
});
PostWithAuthor
import { PostResource, UserResource } from './Resources';

export default function PostWithAuthor({ id }: { id: string }) {
  const post = useSuspense(PostResource.get, { id });
  // post as Post
  const author = useSuspense(
    UserResource.get,
    post.userId
      ? {
          id: post.userId,
        }
      : null,
  );
  // author as User | undefined
  if (!author) return;
}

Embedded data

When entities are stored in nested structures, that structure will remain.

api/Post
export class PaginatedPost extends Entity {
  id = '';
  title = '';
  content = '';

  pk() {
    return this.id;
  }
  static key = 'PaginatedPost';
}

export const getPosts = new RestEndpoint({
  path: '/post',
  searchParams: { page: '' },
  schema: {
    results: new schema.Collection([PaginatedPost]),
    nextPage: '',
    lastPage: '',
  },
});
ArticleList
import { getPosts } from './api/Post';

export default function ArticleList({ page }: { page: string }) {
  const {
    results: posts,
    nextPage,
    lastPage,
  } = useSuspense(getPosts, { page });
  return (
    <div>
      {posts.map(post => (
        <div key={post.pk()}>{post.title}</div>
      ))}
    </div>
  );
}

Server Side Rendering

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.

More Demos

Concurrent Mode

In React 18 navigating with startTransition allows AsyncBoundaries to continue showing the previous screen while the new data loads. Combined with streaming server side rendering, this eliminates the need to flash annoying loading indicators - improving the user experience.

Click one of the names to navigate to their todos. Here long loading states are indicated by the less intrusive loading bar, like YouTube and Robinhood use.

More Demos