npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@mutasimalmu/use-paginated-list-query

v1.0.4

Published

> A powerful React hook for managing client-side paginated data with filters, automatic refetching, and built-in loading states

Downloads

13

Readme

usePaginatedList

A powerful React hook for managing client-side paginated data with filters, automatic refetching, and built-in loading states

✨ Features

  • 🚀 Client-Side Pagination - Efficiently handle large datasets with automatic page management
  • 🔍 Built-in Filtering - Seamlessly filter data with automatic refetch on filter changes
  • Debounced Requests - Prevent excessive API calls with configurable debounce timing (default: 500ms)
  • 🎯 TypeScript Support - Full type inference for your fetch methods and data structures
  • 🔄 Auto-Refetch - Automatically refetches data when page or filters change
  • 💪 Request Abortion - Automatic cleanup and abortion of pending requests
  • 🎨 Loading & Error States - Built-in loading and error state management
  • 🛠️ Flexible API - Works with any paginated API endpoint
  • 🔌 Framework Agnostic - Works with any React-based application

📦 Installation

npm install @mutasimalmu/use-paginated-list-query

or

yarn add @mutasimalmu/use-paginated-list-query

🚀 Quick Start

With Ant Design Table

import usePaginatedList, {
  PaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

async function fetchUsers(
  args: PaginatedListRequest
): Promise<PaginatedListResponse<User>> {
  const response = await fetch(
    `/api/users?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
}

function UsersPage() {
  const {
    data,
    isLoading,
    page,
    pageSize,
    totalCount,
    changePage,
  } = usePaginatedList({
    fetchMethod: fetchUsers,
    pageSize: 10,
  });

  const columns: ColumnsType<User> = [
    {
      title: 'Name',
      dataIndex: 'name',
      key: 'name',
    },
    {
      title: 'Email',
      dataIndex: 'email',
      key: 'email',
    },
    {
      title: 'Role',
      dataIndex: 'role',
      key: 'role',
    },
  ];

  return (
    <Table
      columns={columns}
      dataSource={data}
      loading={isLoading}
      rowKey="id"
      pagination={{
        current: page,
        pageSize: pageSize,
        total: totalCount,
        onChange: changePage,
      }}
    />
  );
}

With shadcn/ui Components

import usePaginatedList, {
  PaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
}

async function fetchProducts(
  args: PaginatedListRequest
): Promise<PaginatedListResponse<Product>> {
  const response = await fetch(
    `/api/products?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
}

function ProductsPage() {
  const {
    data: products,
    isLoading,
    page,
    pageSize,
    totalCount,
    changePage,
  } = usePaginatedList({
    fetchMethod: fetchProducts,
    pageSize: 20,
  });

  const totalPages = Math.ceil(totalCount / pageSize);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div className="space-y-4">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Name</TableHead>
            <TableHead>Price</TableHead>
            <TableHead>Category</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {products.map((product) => (
            <TableRow key={product.id}>
              <TableCell>{product.name}</TableCell>
              <TableCell>${product.price}</TableCell>
              <TableCell>{product.category}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>

      <div className="flex items-center justify-between">
        <Button
          variant="outline"
          onClick={() => changePage(page - 1)}
          disabled={page === 1}
        >
          Previous
        </Button>
        <span>
          Page {page} of {totalPages}
        </span>
        <Button
          variant="outline"
          onClick={() => changePage(page + 1)}
          disabled={page === totalPages}
        >
          Next
        </Button>
      </div>
    </div>
  );
}

📚 API Reference

Hook Signature

function usePaginatedList<T extends FetchMethod>(props: Props<T>): PaginatedListResult

Props

| Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| | fetchMethod | FetchMethod | ✅ Yes | - | Your API fetch function that returns paginated data | | pageSize | number | ❌ No | 10 | Number of items per page | | debounceTime | number | ❌ No | 500 | Debounce time in milliseconds for filter changes | | initialState | Partial<State> | ❌ No | {} | Initial state for filters, data, page, etc. | | disabled | boolean | ❌ No | false | When true, prevents automatic data fetching | | pathParams | object | ❌ Conditional | - | Path parameters required by your fetch method (if needed) |

Note: The hook initializes with filters: { searchValue: "" } by default. You can override this using the initialState prop.

Return Value

| Property | Type | Description | |----------|------|-------------| | data | T[] | Array of fetched items | | isLoading | boolean | Loading state indicator | | page | number | Current page number (1-indexed) | | pageSize | number | Number of items per page | | totalCount | number | Total number of items across all pages | | filters | F | Current filter values | | onFiltersChange | (filters: F) => void | Function to update filters | | changePage | (page: number) => void | Function to change the current page | | refetchData | () => Promise<void> | Manually trigger a data refetch | | resetFilters | () => void | Reset all filters to empty state | | error | unknown | Error object if fetch failed | | abort | () => void | Manually abort the current request |

🎯 Fetch Method Requirements

The package exports utility types and interfaces to help you build type-safe fetch methods. Import and use these types for proper TypeScript inference:

import {
  PaginatedListRequest,
  FilterablePaginatedListRequest,
  PathablePaginatedListRequest,
  FilterablePathablePaginatedListRequest,
  PaginatedListResponse,
  // Or use the pre-built method types:
  FetchPaginatedListMethod,
  FetchFilterablePaginatedListMethod,
  FetchPathablePaginatedListMethod,
  FetchFilterablePathablePaginatedListMethod,
} from '@mutasimalmu/use-paginated-list-query';

Basic Fetch Method (no filters, no path params)

Using Request/Response interfaces:

async function fetchUsers(
  args: PaginatedListRequest
): Promise<PaginatedListResponse<User>> {
  const response = await fetch(
    `/api/users?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
}

Or using the pre-built method type:

const fetchUsers: FetchPaginatedListMethod<User> = async (args) => {
  const response = await fetch(
    `/api/users?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
};

With Filters

Using Request/Response interfaces:

interface ProductFilters {
  searchValue?: string;
  category?: string;
}

async function fetchProducts(
  args: FilterablePaginatedListRequest<ProductFilters>
): Promise<PaginatedListResponse<Product>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });
  const response = await fetch(`/api/products?${params}`, { signal: args.signal });
  return response.json();
}

Or using the pre-built method type:

const fetchProducts: FetchFilterablePaginatedListMethod<Product, ProductFilters> = async (args) => {
  // Implementation
};

With Path Parameters

Using Request/Response interfaces:

interface CategoryPathParams {
  categoryId: string;
}

async function fetchCategoryProducts(
  args: PathablePaginatedListRequest<CategoryPathParams>
): Promise<PaginatedListResponse<Product>> {
  const response = await fetch(
    `/api/categories/${args.pathParams.categoryId}/products?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
}

Or using the pre-built method type:

const fetchCategoryProducts: FetchPathablePaginatedListMethod<Product, CategoryPathParams> = async (args) => {
  // Implementation
};

With Both Filters and Path Parameters

Using Request/Response interfaces:

interface ProductFilters {
  searchValue?: string;
  minPrice?: number;
}

interface CategoryPathParams {
  categoryId: string;
}

async function fetchCategoryProducts(
  args: FilterablePathablePaginatedListRequest<ProductFilters, CategoryPathParams>
): Promise<PaginatedListResponse<Product>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });
  const response = await fetch(
    `/api/categories/${args.pathParams.categoryId}/products?${params}`,
    { signal: args.signal }
  );
  return response.json();
}

Or using the pre-built method type:

const fetchCategoryProducts: FetchFilterablePathablePaginatedListMethod<
  Product,
  ProductFilters,
  CategoryPathParams
> = async (args) => {
  // Implementation
};

✨ Benefits of using these types:

  • ✅ Full TypeScript inference throughout your application
  • ✅ Compile-time type checking for request/response structures
  • ✅ Better IDE autocomplete and IntelliSense
  • ✅ Prevents common typing mistakes

Note: The hook automatically detects which parameters your fetch method expects and passes them accordingly.

Example Fetch Method with Error Handling

import {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';

interface ProductFilters {
  searchValue?: string;
  category?: string;
  minPrice?: number;
  maxPrice?: number;
}

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  stock: number;
}

async function fetchProducts(
  args: FilterablePaginatedListRequest<ProductFilters>
): Promise<PaginatedListResponse<Product>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });

  const response = await fetch(`/api/products?${params}`, {
    signal: args.signal,
  });

  if (!response.ok) {
    throw new Error('Failed to fetch products');
  }

  return response.json();
}

💡 Advanced Usage Examples

With Filters (Ant Design)

import usePaginatedList, {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Table, Input, Select, Button, Space } from 'antd';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
}

interface ProductFilters {
  searchValue?: string;
  category?: string;
}

async function fetchProducts(
  args: FilterablePaginatedListRequest<ProductFilters>
): Promise<PaginatedListResponse<Product>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });
  const response = await fetch(`/api/products?${params}`, { signal: args.signal });
  return response.json();
}

function ProductsPage() {
  const {
    data,
    isLoading,
    page,
    pageSize,
    totalCount,
    filters,
    onFiltersChange,
    changePage,
    resetFilters,
  } = usePaginatedList({
    fetchMethod: fetchProducts,
    pageSize: 25,
    initialState: {
      filters: {
        category: 'electronics',
      },
    },
  });

  const columns = [
    { title: 'Name', dataIndex: 'name', key: 'name' },
    { title: 'Price', dataIndex: 'price', key: 'price', render: (price) => `$${price}` },
    { title: 'Category', dataIndex: 'category', key: 'category' },
  ];

  return (
    <div>
      <Space style={{ marginBottom: 16 }}>
        <Input.Search
          placeholder="Search products..."
          value={filters.searchValue}
          onChange={(e) => onFiltersChange({ searchValue: e.target.value })}
          style={{ width: 200 }}
        />
        <Select
          placeholder="Select category"
          value={filters.category}
          onChange={(value) => onFiltersChange({ category: value })}
          style={{ width: 150 }}
        >
          <Select.Option value="electronics">Electronics</Select.Option>
          <Select.Option value="clothing">Clothing</Select.Option>
          <Select.Option value="books">Books</Select.Option>
        </Select>
        <Button onClick={resetFilters}>Reset Filters</Button>
      </Space>

      <Table
        columns={columns}
        dataSource={data}
        loading={isLoading}
        rowKey="id"
        pagination={{
          current: page,
          pageSize: pageSize,
          total: totalCount,
          onChange: changePage,
        }}
      />
    </div>
  );
}

With Filters (shadcn/ui)

import usePaginatedList, {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

interface Article {
  id: string;
  title: string;
  excerpt: string;
  status: string;
}

interface ArticleFilters {
  searchValue?: string;
  status?: string;
}

async function fetchArticles(
  args: FilterablePaginatedListRequest<ArticleFilters>
): Promise<PaginatedListResponse<Article>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });
  const response = await fetch(`/api/articles?${params}`, { signal: args.signal });
  return response.json();
}

function ArticlesPage() {
  const {
    data: articles,
    isLoading,
    page,
    pageSize,
    totalCount,
    filters,
    onFiltersChange,
    changePage,
    resetFilters,
  } = usePaginatedList({
    fetchMethod: fetchArticles,
    pageSize: 12,
  });

  return (
    <div className="space-y-6">
      <div className="flex gap-4">
        <Input
          placeholder="Search articles..."
          value={filters.searchValue || ''}
          onChange={(e) => onFiltersChange({ searchValue: e.target.value })}
          className="max-w-sm"
        />
        <Select
          value={filters.status}
          onValueChange={(value) => onFiltersChange({ status: value })}
        >
          <SelectTrigger className="w-[180px]">
            <SelectValue placeholder="Select status" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="published">Published</SelectItem>
            <SelectItem value="draft">Draft</SelectItem>
            <SelectItem value="archived">Archived</SelectItem>
          </SelectContent>
        </Select>
        <Button variant="outline" onClick={resetFilters}>
          Reset
        </Button>
      </div>

      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {articles.map((article) => (
            <Card key={article.id}>
              <CardHeader>
                <CardTitle>{article.title}</CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-sm text-muted-foreground">
                  {article.excerpt}
                </p>
              </CardContent>
            </Card>
          ))}
        </div>
      )}

      <div className="flex justify-center gap-2">
        <Button
          variant="outline"
          onClick={() => changePage(page - 1)}
          disabled={page === 1}
        >
          Previous
        </Button>
        <span className="flex items-center px-4">
          Page {page} of {Math.ceil(totalCount / pageSize)}
        </span>
        <Button
          variant="outline"
          onClick={() => changePage(page + 1)}
          disabled={page === Math.ceil(totalCount / pageSize)}
        >
          Next
        </Button>
      </div>
    </div>
  );
}

With Path Parameters

import usePaginatedList, {
  PathablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Table, Tag } from 'antd';

interface CategoryPathParams {
  categoryId: string;
}

interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

async function fetchProductsByCategory(
  args: PathablePaginatedListRequest<CategoryPathParams>
): Promise<PaginatedListResponse<Product>> {
  const response = await fetch(
    `/api/categories/${args.pathParams.categoryId}/products?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
}

function CategoryProductsPage({ categoryId }: { categoryId: string }) {
  const {
    data: products,
    isLoading,
    page,
    pageSize,
    totalCount,
    changePage,
  } = usePaginatedList({
    fetchMethod: fetchProductsByCategory,
    pathParams: { categoryId },
    pageSize: 20,
  });

  const columns = [
    { title: 'Product Name', dataIndex: 'name', key: 'name' },
    {
      title: 'Price',
      dataIndex: 'price',
      key: 'price',
      render: (price) => `$${price.toFixed(2)}`,
    },
    {
      title: 'Status',
      dataIndex: 'inStock',
      key: 'inStock',
      render: (inStock) => (
        <Tag color={inStock ? 'green' : 'red'}>
          {inStock ? 'In Stock' : 'Out of Stock'}
        </Tag>
      ),
    },
  ];

  return (
    <div>
      <h2>Products ({totalCount} total)</h2>
      <Table
        columns={columns}
        dataSource={products}
        loading={isLoading}
        rowKey="id"
        pagination={{
          current: page,
          pageSize: pageSize,
          total: totalCount,
          onChange: changePage,
        }}
      />
    </div>
  );
}

Custom Debounce Time

import usePaginatedList, {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Input, List, Avatar } from 'antd';
import { SearchOutlined } from '@ant-design/icons';

interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

interface UserFilters {
  searchValue?: string;
}

async function fetchUsers(
  args: FilterablePaginatedListRequest<UserFilters>
): Promise<PaginatedListResponse<User>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });
  const response = await fetch(`/api/users?${params}`, { signal: args.signal });
  return response.json();
}

function UserSearchPage() {
  const {
    data: users,
    isLoading,
    onFiltersChange,
  } = usePaginatedList({
    fetchMethod: fetchUsers,
    debounceTime: 300, // Faster response for search (default is 500ms)
    pageSize: 15,
  });

  return (
    <div>
      <Input
        prefix={<SearchOutlined />}
        placeholder="Search users..."
        onChange={(e) => onFiltersChange({ searchValue: e.target.value })}
        style={{ marginBottom: 16 }}
        size="large"
      />
      <List
        loading={isLoading}
        dataSource={users}
        renderItem={(user) => (
          <List.Item>
            <List.Item.Meta
              avatar={<Avatar src={user.avatar} />}
              title={user.name}
              description={user.email}
            />
          </List.Item>
        )}
      />
    </div>
  );
}

Manual Refetch

import usePaginatedList, {
  PaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Table, Button, message } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';

interface Order {
  id: string;
  customerName: string;
  total: number;
  status: string;
}

async function fetchOrders(
  args: PaginatedListRequest
): Promise<PaginatedListResponse<Order>> {
  const response = await fetch(
    `/api/orders?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
}

function OrdersTable() {
  const {
    data: orders,
    isLoading,
    page,
    pageSize,
    totalCount,
    changePage,
    refetchData,
  } = usePaginatedList({
    fetchMethod: fetchOrders,
    pageSize: 10,
  });

  const handleRefresh = async () => {
    await refetchData();
    message.success('Data refreshed successfully!');
  };

  const columns = [
    { title: 'Order ID', dataIndex: 'id', key: 'id' },
    { title: 'Customer', dataIndex: 'customerName', key: 'customerName' },
    { title: 'Total', dataIndex: 'total', key: 'total', render: (total) => `$${total}` },
    { title: 'Status', dataIndex: 'status', key: 'status' },
  ];

  return (
    <div>
      <div style={{ marginBottom: 16 }}>
        <Button
          icon={<ReloadOutlined />}
          onClick={handleRefresh}
          loading={isLoading}
        >
          Refresh Data
        </Button>
      </div>
      <Table
        columns={columns}
        dataSource={orders}
        loading={isLoading}
        rowKey="id"
        pagination={{
          current: page,
          pageSize: pageSize,
          total: totalCount,
          onChange: changePage,
        }}
      />
    </div>
  );
}

Disabled State

import usePaginatedList, {
  PaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Table, Switch, Alert } from 'antd';
import { useState } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
}

async function fetchProducts(
  args: PaginatedListRequest
): Promise<PaginatedListResponse<Product>> {
  const response = await fetch(
    `/api/products?page=${args.page}&page_size=${args.page_size}`,
    { signal: args.signal }
  );
  return response.json();
}

function ConditionalProductsTable() {
  const [enabled, setEnabled] = useState(false);

  const {
    data: products,
    isLoading,
    page,
    pageSize,
    totalCount,
    changePage,
  } = usePaginatedList({
    fetchMethod: fetchProducts,
    disabled: !enabled, // Only fetch when enabled
    pageSize: 10,
  });

  const columns = [
    { title: 'Product', dataIndex: 'name', key: 'name' },
    { title: 'Price', dataIndex: 'price', key: 'price' },
  ];

  return (
    <div>
      <div style={{ marginBottom: 16 }}>
        <Switch
          checked={enabled}
          onChange={setEnabled}
          checkedChildren="Enabled"
          unCheckedChildren="Disabled"
        />
        <span style={{ marginLeft: 8 }}>Toggle data fetching</span>
      </div>

      {!enabled ? (
        <Alert
          message="Data fetching is disabled"
          description="Enable the switch above to start fetching data."
          type="info"
        />
      ) : (
        <Table
          columns={columns}
          dataSource={products}
          loading={isLoading}
          rowKey="id"
          pagination={{
            current: page,
            pageSize: pageSize,
            total: totalCount,
            onChange: changePage,
          }}
        />
      )}
    </div>
  );
}

Complete Example with All Features (Ant Design)

import usePaginatedList, {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Table, Input, Select, Button, Space, Tag, message, Alert } from 'antd';
import { SearchOutlined, ReloadOutlined, ClearOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
  status: 'active' | 'inactive';
  createdAt: string;
}

interface UserFilters {
  searchValue?: string;
  role?: string;
  status?: string;
}

async function fetchUsers(
  args: FilterablePaginatedListRequest<UserFilters>
): Promise<PaginatedListResponse<User>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...(args.filters.searchValue && { search: args.filters.searchValue }),
    ...(args.filters.role && { role: args.filters.role }),
    ...(args.filters.status && { status: args.filters.status }),
  });

  const response = await fetch(`/api/users?${params}`, { signal: args.signal });
  return response.json();
}

function UsersManagementPage() {
  const {
    data: users,
    isLoading,
    page,
    pageSize,
    totalCount,
    filters,
    onFiltersChange,
    changePage,
    refetchData,
    resetFilters,
    error,
  } = usePaginatedList({
    fetchMethod: fetchUsers,
    pageSize: 20,
    debounceTime: 400,
    initialState: {
      filters: {
        status: 'active',
      },
    },
  });

  const handleRefresh = async () => {
    await refetchData();
    message.success('Data refreshed!');
  };

  const columns: ColumnsType<User> = [
    {
      title: 'Name',
      dataIndex: 'name',
      key: 'name',
      sorter: (a, b) => a.name.localeCompare(b.name),
    },
    {
      title: 'Email',
      dataIndex: 'email',
      key: 'email',
    },
    {
      title: 'Role',
      dataIndex: 'role',
      key: 'role',
      render: (role) => <Tag color="blue">{role}</Tag>,
    },
    {
      title: 'Status',
      dataIndex: 'status',
      key: 'status',
      render: (status) => (
        <Tag color={status === 'active' ? 'green' : 'red'}>
          {status.toUpperCase()}
        </Tag>
      ),
    },
    {
      title: 'Created',
      dataIndex: 'createdAt',
      key: 'createdAt',
      render: (date) => new Date(date).toLocaleDateString(),
    },
  ];

  if (error) {
    return (
      <Alert
        message="Error Loading Users"
        description="Failed to load users. Please try again."
        type="error"
        showIcon
        action={
          <Button size="small" onClick={handleRefresh}>
            Retry
          </Button>
        }
      />
    );
  }

  return (
    <div style={{ padding: 24 }}>
      <h1>Users Management</h1>
      
      <Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
        <Space wrap>
          <Input
            prefix={<SearchOutlined />}
            placeholder="Search users..."
            value={filters.searchValue || ''}
            onChange={(e) => onFiltersChange({ searchValue: e.target.value })}
            style={{ width: 250 }}
            allowClear
          />
          
          <Select
            placeholder="Filter by role"
            value={filters.role}
            onChange={(value) => onFiltersChange({ role: value })}
            style={{ width: 150 }}
            allowClear
          >
            <Select.Option value="admin">Admin</Select.Option>
            <Select.Option value="user">User</Select.Option>
            <Select.Option value="moderator">Moderator</Select.Option>
          </Select>

          <Select
            placeholder="Filter by status"
            value={filters.status}
            onChange={(value) => onFiltersChange({ status: value })}
            style={{ width: 150 }}
          >
            <Select.Option value="active">Active</Select.Option>
            <Select.Option value="inactive">Inactive</Select.Option>
          </Select>

          <Button
            icon={<ClearOutlined />}
            onClick={resetFilters}
          >
            Reset Filters
          </Button>

          <Button
            icon={<ReloadOutlined />}
            onClick={handleRefresh}
            loading={isLoading}
          >
            Refresh
          </Button>
        </Space>
      </Space>

      <Table
        columns={columns}
        dataSource={users}
        loading={isLoading}
        rowKey="id"
        pagination={{
          current: page,
          pageSize: pageSize,
          total: totalCount,
          onChange: changePage,
          showSizeChanger: false,
          showTotal: (total) => `Total ${total} users`,
        }}
      />
    </div>
  );
}

🔧 TypeScript

The hook provides full type inference when you use the exported utility types. Always import and use these types for the best TypeScript experience:

import usePaginatedList, {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';

// Define your data types
interface User {
  id: string;
  name: string;
  email: string;
}

interface UserFilters {
  role?: string;
  status?: string;
  searchValue?: string;
}

// ✅ CORRECT: Your fetch method using exported types
const fetchUsers = async (
  args: FilterablePaginatedListRequest<UserFilters>
): Promise<PaginatedListResponse<User>> => {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });
  const response = await fetch(`/api/users?${params}`, { signal: args.signal });
  return response.json();
};

// Hook automatically infers types correctly
const {
  data, // ✅ Type: User[]
  filters, // ✅ Type: UserFilters
  onFiltersChange, // ✅ Type: (filters: UserFilters) => void
} = usePaginatedList({
  fetchMethod: fetchUsers,
});

❌ Common Mistake - Don't Do This:

// ❌ BAD: Writing types manually without imports
const fetchUsers = async (args: {
  page: number;
  page_size: number;
  filters?: UserFilters;
  signal?: AbortSignal;
}): Promise<{
  results: User[];
  count: number;
  next: string | null;
  previous: string | null;
}> => {
  // This works but you lose type inference benefits
  // and won't get compiler errors if the API changes
};

✅ Best Practice - Use Exported Types:

import {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';

// ✅ GOOD: Using exported types ensures:
// - Full type inference
// - Type safety
// - IDE autocomplete
// - Compile-time error detection
const fetchUsers = async (
  args: FilterablePaginatedListRequest<UserFilters>
): Promise<PaginatedListResponse<User>> => {
  // Implementation
};

🎨 Best Practices

1. Keep Fetch Methods Pure

// ✅ Good - Pure function
const fetchProducts = async (args) => {
  return api.get('/products', { params: args });
};

// ❌ Bad - Side effects in fetch method
const fetchProducts = async (args) => {
  showLoadingToast(); // Side effect
  return api.get('/products', { params: args });
};

2. Use Proper Filter Types

// ✅ Good - Explicit filter types
interface ProductFilters {
  searchValue?: string;
  category?: string;
  status?: 'available' | 'out_of_stock';
  minPrice?: number;
  maxPrice?: number;
}

// ❌ Bad - Any type
type ProductFilters = any;

3. Handle Errors Gracefully (with Ant Design)

import { Alert, Spin } from 'antd';

const { data, error, isLoading } = usePaginatedList({
  fetchMethod: fetchProducts,
});

if (error) {
  return (
    <Alert
      message="Error"
      description="Failed to load data. Please try again."
      type="error"
      showIcon
    />
  );
}

if (isLoading) {
  return <Spin size="large" />;
}

return <Table dataSource={data} columns={columns} />;

3b. Handle Errors Gracefully (with shadcn/ui)

import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';

const { data, error, isLoading } = usePaginatedList({
  fetchMethod: fetchProducts,
});

if (error) {
  return (
    <Alert variant="destructive">
      <AlertCircle className="h-4 w-4" />
      <AlertTitle>Error</AlertTitle>
      <AlertDescription>
        Failed to load data. Please try again.
      </AlertDescription>
    </Alert>
  );
}

if (isLoading) {
  return <div>Loading...</div>;
}

return <Table dataSource={data} columns={columns} />;

4. Reset to Page 1 When Filters Change

The hook automatically handles this! When filters change, it maintains the current page. If you want to reset to page 1, do it manually:

const { onFiltersChange, changePage } = usePaginatedList({
  fetchMethod: fetchData,
});

const handleFilterChange = (newFilters) => {
  changePage(1); // Reset to first page
  onFiltersChange(newFilters);
};

🔍 How It Works

  1. Initialization: Hook initializes with default or provided initial state
  2. Auto-Fetch: Automatically fetches data when mounted
  3. Filter Changes: When filters change, debounced fetch is triggered
  4. Page Changes: When page changes, immediate fetch is triggered
  5. Request Abortion: Previous requests are aborted when new requests start
  6. State Updates: Loading, data, and error states are updated accordingly
  7. Cleanup: Aborts pending requests on unmount

🚦 Common Patterns

Pagination Controls

function PaginationControls({ page, totalCount, pageSize, onPageChange }) {
  const totalPages = Math.ceil(totalCount / pageSize);
  
  return (
    <div>
      <button 
        onClick={() => onPageChange(page - 1)} 
        disabled={page === 1}
      >
        Previous
      </button>
      <span>Page {page} of {totalPages}</span>
      <button 
        onClick={() => onPageChange(page + 1)} 
        disabled={page === totalPages}
      >
        Next
      </button>
    </div>
  );
}

Search with Debounce (Ant Design)

import usePaginatedList, {
  FilterablePaginatedListRequest,
  PaginatedListResponse,
} from '@mutasimalmu/use-paginated-list-query';
import { Table, Input, Space } from 'antd';
import { SearchOutlined } from '@ant-design/icons';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
}

interface ProductFilters {
  searchValue?: string;
}

async function fetchProducts(
  args: FilterablePaginatedListRequest<ProductFilters>
): Promise<PaginatedListResponse<Product>> {
  const params = new URLSearchParams({
    page: args.page.toString(),
    page_size: args.page_size.toString(),
    ...args.filters,
  });
  const response = await fetch(`/api/products?${params}`, { signal: args.signal });
  return response.json();
}

function SearchableProductsTable() {
  const {
    data: products,
    filters,
    onFiltersChange,
    isLoading,
    page,
    pageSize,
    totalCount,
    changePage,
  } = usePaginatedList({
    fetchMethod: fetchProducts,
    debounceTime: 500, // Wait 500ms after user stops typing
    pageSize: 10,
  });

  const columns = [
    { title: 'Product', dataIndex: 'name', key: 'name' },
    { title: 'Price', dataIndex: 'price', key: 'price' },
    { title: 'Category', dataIndex: 'category', key: 'category' },
  ];

  return (
    <Space direction="vertical" style={{ width: '100%' }}>
      <Input
        prefix={<SearchOutlined />}
        placeholder="Search products..."
        value={filters.searchValue || ''}
        onChange={(e) => onFiltersChange({ searchValue: e.target.value })}
        allowClear
      />
      <Table
        columns={columns}
        dataSource={products}
        loading={isLoading}
        rowKey="id"
        pagination={{
          current: page,
          pageSize: pageSize,
          total: totalCount,
          onChange: changePage,
        }}
      />
    </Space>
  );
}

🐛 Troubleshooting

Data not fetching?

  • Check that disabled prop is not set to true
  • Verify your fetch method signature matches the required format
  • Check network tab for API errors

Too many requests?

  • Increase debounceTime for filter-heavy UIs
  • Ensure you're not calling onFiltersChange unnecessarily

TypeScript errors?

  • Ensure your fetch method has proper return type annotations
  • Check that your filter types are correctly defined

📄 License

MIT License - see LICENSE file for details

🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.


Keywords: React hook, pagination, client-side pagination, data fetching, filters, TypeScript, debounce, loading states, abort controller, React Query alternative