Skip to main content

Rest Pagination

Expanding Lists

In case you want to append results to your existing list, rather than move to another page Resource.getList.getPage can be used as long as paginationField was provided.

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

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';
}
import { Entity, createResource } from '@data-client/rest';
import { User } from './User';

export class Post extends Entity {
  id = 0;
  author = User.fromJS();
  title = '';
  body = '';

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

  static schema = {
    author: User,
  };
}
export const PostResource = createResource({
  path: '/posts/:id',
  schema: Post,
  paginationField: 'cursor',
}).extend('getList', {
  schema: { results: new schema.Collection([Post]), cursor: '' },
});
import { type Post } from './Post';

export default function PostItem({ post }: Props) {
  return (
    <div className="listItem spaced">
      <Avatar src={post.author.profileImage} />
      <div>
        <h4>{post.title}</h4>
        <small>by {post.author.name}</small>
      </div>
    </div>
  );
}

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

export default function PostList() {
  const { results, cursor } = useSuspense(PostResource.getList);
  const ctrl = useController();
  const handlePageLoad = () =>
    ctrl.fetch(PostResource.getList.getPage, { cursor });
  return (
    <div>
      {results.map(post => (
        <PostItem key={post.pk()} post={post} />
      ))}
      {cursor ? (
        <center>
          <button onClick={handlePageLoad}>Load more</button>
        </center>
      ) : null}
    </div>
  );
}
render(<PostList />);
🔴 Live Preview
Store

Don't forget to define our Resource's paginationField and correct schema!

Post
export const PostResource = createResource({
path: '/posts/:id',
schema: Post,
paginationField: 'cursor',
}).extend('getList', {
schema: { results: new schema.Collection([Post]), cursor: '' },
});

Github Issues Demo

Our NextPage component has a click handler that calls RestEndpoint.getPage. Scroll to the bottom of the preview to click "Load more" to append the next page of issues.

More Demos

Using RestEndpoint Directly

Here we explore a real world example using cosmos validators list.

Since validators only have one Endpoint, we use RestEndpoint instead of createResource. By using Collections and paginationField, we can call RestEndpoint.getPage to append the next page of validators to our list.

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

export class Validator extends Entity {
  operator_address = '';
  consensus_pubkey = { '@type': '', key: '' };
  jailed = false;
  status = 'BOND_STATUS_BONDED';
  tokens = '0';
  delegator_shares = '0';
  description = {
    moniker: '',
    identity: '',
    website: 'https://fake.com',
    security_contact: '',
    details: '',
  };
  unbonding_height = '0';
  unbonding_time = Temporal.Instant.fromEpochSeconds(0);
  comission = {
    commission_rates: { rate: 0, max_rate: 0, max_change_rate: 0 },
    update_time: Temporal.Instant.fromEpochSeconds(0),
  };
  min_self_delegation = '0';

  pk() {
    return this.operator_address;
  }

  static schema = {
    unbonding_time: Temporal.Instant.from,
    comission: {
      commission_rates: {
        rate: Number,
        max_rate: Number,
        max_change_rate: Number,
      },
      update_time: Temporal.Instant.from,
    },
  };
}

export const getValidators = new RestEndpoint({
  urlPrefix: 'https://rest.cosmos.directory',
  path: '/stargaze/cosmos/staking/v1beta1/validators',
  searchParams: {} as { 'pagination.limit': string },
  paginationField: 'pagination.key',
  schema: {
    validators: new schema.Collection([Validator]),
    pagination: { next_key: '', total: '' },
  },
});
import { type Validator } from './Validator';

export default function ValidatorItem({ validator }: Props) {
  return (
    <div className="listItem spaced">
      <div>
        <h4>{validator.description.moniker}</h4>
        <small>
          <a href={validator.description.website} target="_blank">
            {validator.description.website}
          </a>
        </small>
        <p>{validator.description.details}</p>
      </div>
    </div>
  );
}

interface Props {
  validator: Validator;
}
import { useSuspense } from '@data-client/react';
import ValidatorItem from './ValidatorItem';
import { getValidators } from './Validator';

const PAGE_LIMIT = '3';

export default function ValidatorList() {
  const { validators, pagination } = useSuspense(getValidators, {
    'pagination.limit': PAGE_LIMIT,
  });
  const ctrl = useController();
  const handleLoadMore = () =>
    ctrl.fetch(getValidators.getPage, {
      'pagination.limit': PAGE_LIMIT,
      'pagination.key': pagination.next_key,
    });

  return (
    <div>
      {validators.map(validator => (
        <ValidatorItem key={validator.pk()} validator={validator} />
      ))}
      {pagination.next_key ? (
        <center>
          <button onClick={handleLoadMore}>Load more</button>
        </center>
      ) : null}
    </div>
  );
}
render(<ValidatorList />);
🔴 Live Preview
Store

Infinite Scrolling

Since UI behaviors vary widely, and implementations vary from platform (react-native or web), we'll just assume a Pagination component is built, that uses a callback to trigger next page fetching. On web, it is recommended to use something based on Intersection Observers

import { useSuspense, useController } from '@data-client/react';
import { PostResource } from 'resources/Post';

function NewsList() {
const { results, cursor } = useSuspense(PostResource.getList);
const ctrl = useController();

return (
<Pagination
onPaginate={() =>
ctrl.fetch(PostResource.getList.getPage, { cursor })
}
>
<NewsList data={results} />
</Pagination>
);
}

Tokens in HTTP Headers

In some cases the pagination tokens will be embeded in HTTP headers, rather than part of the payload. In this case you'll need to customize the parseResponse() function for getList so the pagination headers are included fetch object.

We show the custom getList below. All other parts of the above example remain the same.

Pagination token is stored in the header link for this example.

import { Resource } from '@data-client/rest';

export const ArticleResource = createResource({
path: '/articles/:id',
schema: Article,
}).extend(Base => ({
getList: Base.getList.extend({
schema: { results: [Article], link: '' },
async parseResponse(response: Response) {
const results = await Base.getList.parseResponse(response);
if (
(response.headers && response.headers.has('link')) ||
Array.isArray(results)
) {
return {
link: response.headers.get('link'),
results,
};
}
return results;
},
}),
}));

Code organization

If much of your API share a similar pagination, you might try a custom Endpoint class that shares this logic.

api/PagingEndpoint.ts
import { RestEndpoint, type RestGenerics } from '@data-client/rest';

export class PagingEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async parseResponse(response: Response) {
const results = await super.parseResponse(response);
if (
(response.headers && response.headers.has('link')) ||
Array.isArray(results)
) {
return {
link: response.headers.get('link'),
results,
};
}
return results;
}
}
api/My.ts
import { createResource, Entity } from '@data-client/rest';

import { PagingEndpoint } from './PagingEndpoint';

export const MyResource = createResource({
path: '/stuff/:id',
schema: MyEntity,
Endpoint: PagingEndpoint,
});