Skip to content

Pagination

Use when: you need to page through a large server-side list — cursor-based or offset/page-based — with loading state per page and optional prefetch. Don’t use when: the full list fits in memory comfortably; just load everything once and slice client-side.

The most common shape: a page number plus a totalPages count returned by the server.

interface
interface PaginationState
PaginationState
{
PaginationState.items: Article[]
items
:
interface Article
Article
[];
PaginationState.page: number
page
: number;
PaginationState.totalPages: number
totalPages
: number;
PaginationState.status: "idle" | "loading" | "success" | "error"
status
: 'idle' | 'loading' | 'success' | 'error';
PaginationState.error: string | null
error
: string | null;
}
class
class ArticleListCubit
ArticleListCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

Cubit<S> is a StateContainer<S> with emit / patch exposed as public mutation surface. Today it adds nothing structurally beyond StateContainer — both are inherited from the underlying StructuralContainer<S>. Kept as a real class (not a type alias) because downstream code does instance instanceof Cubit checks (see A2 audit).

The class body is intentionally empty: a no-op emit override would still go through applyState, and patch is inherited from StructuralContainer (path-diffed, microtask-flushed). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<
interface PaginationState
PaginationState
> {
private readonly
ArticleListCubit.perPage: 20
perPage
= 20;
private
ArticleListCubit.requestId: number
requestId
= 0;
constructor() {
super({
PaginationState.items: Article[]
items
: [],
PaginationState.page: number
page
: 1,
PaginationState.totalPages: number
totalPages
: 1,
PaginationState.status: "idle" | "loading" | "success" | "error"
status
: 'idle',
PaginationState.error: string | null
error
: null });
}
protected override async
ArticleListCubit.init(): Promise<void>

Called once after construction with the args passed at acquire time, before the first state snapshot is read by any consumer. Override to seed args-derived state (via this.emit(...)) or kick off loads.

init
() {
await this.
ArticleListCubit.loadPage: (page: number) => Promise<void>
loadPage
(1);
}
ArticleListCubit.loadPage: (page: number) => Promise<void>
loadPage
= async (
page: number
page
: number) => {
const
const reqId: number
reqId
= ++this.
ArticleListCubit.requestId: number
requestId
;
this.
StateContainer<PaginationState, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: string | undefined;
title?: string | undefined;
}[] | undefined;
page?: number | undefined;
totalPages?: number | undefined;
status?: "idle" | "loading" | "success" | "error" | undefined;
error?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
status?: "idle" | "loading" | "success" | "error" | undefined
status
: 'loading',
error?: string | null | undefined
error
: null });
try {
const {
const items: Article[]
items
,
const totalPages: number
totalPages
} = await
const api: {
listArticles(page: number, perPage: number): Promise<PageResult>;
}
api
.
function listArticles(page: number, perPage: number): Promise<PageResult>
listArticles
(
page: number
page
, this.
ArticleListCubit.perPage: 20
perPage
);
if (
const reqId: number
reqId
!== this.
ArticleListCubit.requestId: number
requestId
) return;
this.
StateContainer<PaginationState, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: string | undefined;
title?: string | undefined;
}[] | undefined;
page?: number | undefined;
totalPages?: number | undefined;
status?: "idle" | "loading" | "success" | "error" | undefined;
error?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
items?: readonly {
id?: string | undefined;
title?: string | undefined;
}[] | undefined
items
,
page?: number | undefined
page
,
totalPages?: number | undefined
totalPages
,
status?: "idle" | "loading" | "success" | "error" | undefined
status
: 'success' });
} catch (
function (local var) e: unknown
e
) {
if (
const reqId: number
reqId
!== this.
ArticleListCubit.requestId: number
requestId
) return;
this.
StateContainer<PaginationState, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: string | undefined;
title?: string | undefined;
}[] | undefined;
page?: number | undefined;
totalPages?: number | undefined;
status?: "idle" | "loading" | "success" | "error" | undefined;
error?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
status?: "idle" | "loading" | "success" | "error" | undefined
status
: 'error',
error?: string | null | undefined
error
:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) e: unknown
e
) });
}
};
ArticleListCubit.nextPage: () => void
nextPage
= () => {
if (this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.page: number
page
< this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.totalPages: number
totalPages
) {
void this.
ArticleListCubit.loadPage: (page: number) => Promise<void>
loadPage
(this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.page: number
page
+ 1);
}
};
ArticleListCubit.prevPage: () => void
prevPage
= () => {
if (this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.page: number
page
> 1) {
void this.
ArticleListCubit.loadPage: (page: number) => Promise<void>
loadPage
(this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.page: number
page
- 1);
}
};
get
ArticleListCubit.hasNext: boolean
hasNext
() {
return this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.page: number
page
< this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.totalPages: number
totalPages
;
}
get
ArticleListCubit.hasPrev: boolean
hasPrev
() {
return this.
StructuralContainer<PaginationState>.state: PaginationState
state
.
PaginationState.page: number
page
> 1;
}
}
function ArticleList() {
const [state, list] = useBloc(ArticleListCubit, {
select: (s, bloc) => [
s.items,
s.page,
s.totalPages,
s.status,
bloc.hasNext,
bloc.hasPrev,
],
});
if (state.status === 'loading') return <p>Loading…</p>;
if (state.status === 'error') return <p>Error: {state.error}</p>;
return (
<div>
<ul>
{state.items.map((a) => (
<li key={a.id}>{a.title}</li>
))}
</ul>
<button onClick={list.prevPage} disabled={!list.hasPrev}>
← Prev
</button>
<span>
Page {state.page} / {state.totalPages}
</span>
<button onClick={list.nextPage} disabled={!list.hasNext}>
Next →
</button>
</div>
);
}

Cursor-based (infinite scroll / “load more”)

Section titled “Cursor-based (infinite scroll / “load more”)”

Cursor pagination keeps appending items rather than replacing them. The server returns an opaque nextCursor; a null cursor signals the end.

interface
interface FeedState
FeedState
{
FeedState.posts: Post[]
posts
:
interface Post
Post
[];
FeedState.cursor: string | null
cursor
: string | null; // null = no more pages
FeedState.status: "idle" | "loading" | "success" | "error"
status
: 'idle' | 'loading' | 'success' | 'error';
FeedState.error: string | null
error
: string | null;
}
class
class FeedCubit
FeedCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

Cubit<S> is a StateContainer<S> with emit / patch exposed as public mutation surface. Today it adds nothing structurally beyond StateContainer — both are inherited from the underlying StructuralContainer<S>. Kept as a real class (not a type alias) because downstream code does instance instanceof Cubit checks (see A2 audit).

The class body is intentionally empty: a no-op emit override would still go through applyState, and patch is inherited from StructuralContainer (path-diffed, microtask-flushed). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<
interface FeedState
FeedState
> {
private
FeedCubit.requestId: number
requestId
= 0;
constructor() {
super({
FeedState.posts: Post[]
posts
: [],
FeedState.cursor: string | null
cursor
: null,
FeedState.status: "idle" | "loading" | "success" | "error"
status
: 'idle',
FeedState.error: string | null
error
: null });
}
protected override async
FeedCubit.init(): Promise<void>

Called once after construction with the args passed at acquire time, before the first state snapshot is read by any consumer. Override to seed args-derived state (via this.emit(...)) or kick off loads.

init
() {
await this.
FeedCubit.loadMore: () => Promise<void>
loadMore
();
}
FeedCubit.loadMore: () => Promise<void>
loadMore
= async () => {
// Guard: do nothing if already loading or no more pages.
if (
this.
StructuralContainer<FeedState>.state: FeedState
state
.
FeedState.status: "idle" | "loading" | "success" | "error"
status
=== 'loading' ||
(this.
StructuralContainer<FeedState>.state: FeedState
state
.
FeedState.status: "idle" | "success" | "error"
status
=== 'success' && this.
StructuralContainer<FeedState>.state: FeedState
state
.
FeedState.cursor: string | null
cursor
=== null)
) {
return;
}
const
const reqId: number
reqId
= ++this.
FeedCubit.requestId: number
requestId
;
this.
StateContainer<FeedState, void, Record<string, never>>.patch(partial: {
posts?: readonly {
id?: string | undefined;
body?: string | undefined;
}[] | undefined;
cursor?: string | null | undefined;
status?: "idle" | "loading" | "success" | "error" | undefined;
error?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
status?: "idle" | "loading" | "success" | "error" | undefined
status
: 'loading',
error?: string | null | undefined
error
: null });
try {
const {
const posts: Post[]
posts
,
const nextCursor: string | null
nextCursor
} = await
const api: {
fetchPosts(cursor: string | null): Promise<{
posts: Post[];
nextCursor: string | null;
}>;
}
api
.
function fetchPosts(cursor: string | null): Promise<{
posts: Post[];
nextCursor: string | null;
}>
fetchPosts
(this.
StructuralContainer<FeedState>.state: FeedState
state
.
FeedState.cursor: string | null
cursor
);
if (
const reqId: number
reqId
!== this.
FeedCubit.requestId: number
requestId
) return;
// Append — do not replace the existing list.
this.
StateContainer<FeedState, void, Record<string, never>>.patch(partial: {
posts?: readonly {
id?: string | undefined;
body?: string | undefined;
}[] | undefined;
cursor?: string | null | undefined;
status?: "idle" | "loading" | "success" | "error" | undefined;
error?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
posts?: readonly {
id?: string | undefined;
body?: string | undefined;
}[] | undefined
posts
: [...this.
StructuralContainer<FeedState>.state: FeedState
state
.
FeedState.posts: Post[]
posts
, ...
const posts: Post[]
posts
],
cursor?: string | null | undefined
cursor
:
const nextCursor: string | null
nextCursor
,
status?: "idle" | "loading" | "success" | "error" | undefined
status
: 'success',
});
} catch (
function (local var) e: unknown
e
) {
if (
const reqId: number
reqId
!== this.
FeedCubit.requestId: number
requestId
) return;
this.
StateContainer<FeedState, void, Record<string, never>>.patch(partial: {
posts?: readonly {
id?: string | undefined;
body?: string | undefined;
}[] | undefined;
cursor?: string | null | undefined;
status?: "idle" | "loading" | "success" | "error" | undefined;
error?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
status?: "idle" | "loading" | "success" | "error" | undefined
status
: 'error',
error?: string | null | undefined
error
:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) e: unknown
e
) });
}
};
get
FeedCubit.hasMore: boolean
hasMore
() {
return this.
StructuralContainer<FeedState>.state: FeedState
state
.
FeedState.cursor: string | null
cursor
!== null;
}
}
function Feed() {
const [state, feed] = useBloc(FeedCubit, {
select: (s, bloc) => [s.posts, s.status, bloc.hasMore],
});
return (
<div>
{state.posts.map((p) => (
<div key={p.id}>{p.body}</div>
))}
{feed.hasMore && (
<button onClick={feed.loadMore} disabled={state.status === 'loading'}>
{state.status === 'loading' ? 'Loading…' : 'Load more'}
</button>
)}
</div>
);
}
  • Async — request-id guard and the loadable surface
  • Cubitpatch deep-merges, emit replaces wholesale