Ark UI Logo
Collections
Async list

Async List

A hook for managing asynchronous data operations in list collections including loading, filtering, sorting, and pagination.

The useAsyncList hook manages asynchronous data operations for list collections. It provides a comprehensive solution for loading, filtering, sorting, and paginating data with built-in loading states, error handling, and request cancellation.

import { useAsyncList } from '@ark-ui/react/collection'

const list = useAsyncList<User>({
  async load({ signal }) {
    const response = await fetch('/api/users', { signal })
    const users = await response.json()
    return { items: users }
  },
})

console.log(list.items) // User[]
console.log(list.loading) // boolean
console.log(list.error) // Error | null

Examples

Basic Usage

The most basic usage involves providing a load function that returns data.

import { useAsyncList } from '@ark-ui/react/collection'

interface Quote {
  id: number
  quote: string
  author: string
}

export const Reload = () => {
  const list = useAsyncList<Quote>({
    async load() {
      const response = await fetch(`https://dummyjson.com/quotes?limit=5&skip=${Math.floor(Math.random() * 50)}`)

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

      const data = await response.json()
      return { items: data.quotes }
    },
  })

  return (
    <div>
      <div>
        <button onClick={() => list.reload()} disabled={list.loading}>
          {list.loading ? 'Loading...' : 'Reload Quotes'}
        </button>
      </div>

      {list.error && <div>Error: {list.error.message}</div>}

      <div>
        {list.items.map((quote) => (
          <div key={quote.id}>
            <div>"{quote.quote}"</div>
            <div>— {quote.author}</div>
          </div>
        ))}
      </div>
    </div>
  )
}

Infinite Loading

Implement pagination by returning a cursor that indicates more data is available.

import { useAsyncList } from '@ark-ui/react/collection'

interface Post {
  userId: number
  id: number
  title: string
  body: string
}

export const InfiniteLoading = () => {
  const list = useAsyncList<Post, number>({
    autoReload: true,
    async load({ cursor }) {
      const page = cursor || 1
      const limit = 10
      const start = (page - 1) * limit

      const response = await fetch(`https://jsonplaceholder.typicode.com/posts?_start=${start}&_limit=${limit}`)

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

      const posts: Post[] = await response.json()
      const hasNextPage = posts.length === limit

      return {
        items: posts,
        cursor: hasNextPage ? page + 1 : undefined,
      }
    },
  })

  return (
    <div>
      <div>
        Loaded {list.items.length} posts
        {list.cursor && ` (more available)`}
      </div>

      {list.cursor && (
        <button onClick={() => list.loadMore()} disabled={list.loading}>
          {list.loading ? 'Loading...' : 'Load More'}
        </button>
      )}

      {list.error && <div>Error: {list.error.message}</div>}

      <div>
        {list.items.map((post, index) => (
          <div key={post.id}>
            <div>
              <strong>{index + 1}: </strong>
              <strong>{post.title}</strong>
            </div>
            <div>{post.body}</div>
          </div>
        ))}
      </div>
    </div>
  )
}

Filtering

Filter data based on user input with automatic debouncing and loading states.

import { useAsyncList } from '@ark-ui/react/collection'

interface User {
  id: number
  name: string
  email: string
  department: string
  role: string
}

export const Filter = () => {
  const list = useAsyncList<User>({
    initialItems: mockUsers.slice(0, 5), // Show first 5 users initially
    async load({ filterText }) {
      await delay(500) // Simulate network delay

      if (!filterText) {
        return { items: mockUsers.slice(0, 5) }
      }

      const filtered = mockUsers.filter(
        (user) =>
          user.name.toLowerCase().includes(filterText.toLowerCase()) ||
          user.email.toLowerCase().includes(filterText.toLowerCase()),
      )

      return { items: filtered }
    },
  })

  return (
    <div>
      <div>
        <input
          type="text"
          placeholder="Search users..."
          value={list.filterText}
          onChange={(e) => list.setFilterText(e.target.value)}
        />
        {list.loading && <span>Loading...</span>}
      </div>

      {list.error && <div>Error: {list.error.message}</div>}

      <div>
        {list.items.map((user) => (
          <div key={user.id}>
            <div>
              <strong>{user.name}</strong>
            </div>
            <div>{user.email}</div>
            <div>
              {user.department}{user.role}
            </div>
          </div>
        ))}
      </div>

      {list.items.length === 0 && !list.loading && <div>No results found</div>}
    </div>
  )
}

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

const mockUsers: User[] = [
  { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering', role: 'Senior Developer' },
  { id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Marketing', role: 'Marketing Manager' },
  { id: 3, name: 'Carol Davis', email: 'carol@example.com', department: 'Engineering', role: 'Frontend Developer' },
  { id: 4, name: 'David Wilson', email: 'david@example.com', department: 'Sales', role: 'Sales Representative' },
  { id: 5, name: 'Eva Brown', email: 'eva@example.com', department: 'Engineering', role: 'DevOps Engineer' },
  { id: 6, name: 'Frank Miller', email: 'frank@example.com', department: 'Support', role: 'Customer Success' },
  { id: 7, name: 'Grace Lee', email: 'grace@example.com', department: 'Marketing', role: 'Content Creator' },
  { id: 8, name: 'Henry Taylor', email: 'henry@example.com', department: 'Engineering', role: 'Backend Developer' },
  { id: 9, name: 'Ivy Anderson', email: 'ivy@example.com', department: 'Sales', role: 'Account Manager' },
  { id: 10, name: 'Jack Thompson', email: 'jack@example.com', department: 'Support', role: 'Technical Support' },
  { id: 11, name: 'Kate Martinez', email: 'kate@example.com', department: 'Marketing', role: 'Brand Manager' },
  { id: 12, name: 'Liam Garcia', email: 'liam@example.com', department: 'Engineering', role: 'Full Stack Developer' },
  { id: 13, name: 'Mia Rodriguez', email: 'mia@example.com', department: 'Sales', role: 'Sales Director' },
  { id: 14, name: 'Noah Lopez', email: 'noah@example.com', department: 'Support', role: 'Support Manager' },
  { id: 15, name: 'Olivia White', email: 'olivia@example.com', department: 'Engineering', role: 'UI Designer' },
  { id: 16, name: 'Paul Harris', email: 'paul@example.com', department: 'Marketing', role: 'Digital Marketer' },
  { id: 17, name: 'Quinn Clark', email: 'quinn@example.com', department: 'Engineering', role: 'Mobile Developer' },
  { id: 18, name: 'Ruby Lewis', email: 'ruby@example.com', department: 'Sales', role: 'Business Development' },
  { id: 19, name: 'Sam Young', email: 'sam@example.com', department: 'Support', role: 'Documentation Specialist' },
  { id: 20, name: 'Tina Walker', email: 'tina@example.com', department: 'Marketing', role: 'Social Media Manager' },
]

Sorting (Client-side)

Sort data on the client side after loading from the server.

import { useAsyncList } from '@ark-ui/react/collection'
import { useCollator } from '@ark-ui/react/locale'

interface User {
  id: number
  name: string
  username: string
  email: string
  phone: string
  website: string
}

export const SortClientSide = () => {
  const collator = useCollator()

  const list = useAsyncList<User>({
    autoReload: true,
    load: async () => {
      const response = await fetch('https://jsonplaceholder.typicode.com/users')
      const data = await response.json()
      return { items: data }
    },
    sort({ items, descriptor }) {
      return {
        items: items.sort((a, b) => {
          const { column, direction } = descriptor
          let cmp = collator.compare(String(a[column]), String(b[column]))
          if (direction === 'descending') {
            cmp *= -1
          }
          return cmp
        }),
      }
    },
  })

  const handleSort = (column: keyof User) => {
    const currentSort = list.sortDescriptor
    let direction: 'ascending' | 'descending' = 'ascending'

    if (currentSort?.column === column && currentSort.direction === 'ascending') {
      direction = 'descending'
    }

    list.sort({ column, direction })
  }

  const getSortIcon = (column: keyof User) => {
    const current = list.sortDescriptor
    if (current?.column !== column) return '↕️'
    return current.direction === 'ascending' ? '↑' : '↓'
  }

  const descriptor = list.sortDescriptor

  return (
    <div>
      <div>
        {list.loading && <div>Loading...</div>}
        {list.error && <div>Error: {list.error.message}</div>}
      </div>
      <div>Sorted by: {descriptor ? `${descriptor.column} (${descriptor.direction})` : 'none'}</div>

      <table>
        <thead>
          <tr>
            {[
              { key: 'name', label: 'Name' },
              { key: 'username', label: 'Username' },
              { key: 'email', label: 'Email' },
              { key: 'phone', label: 'Phone' },
              { key: 'website', label: 'Website' },
            ].map(({ key, label }) => (
              <th key={key} onClick={() => handleSort(key as keyof User)}>
                {label} {getSortIcon(key as keyof User)}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {list.items.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.username}</td>
              <td>{user.email}</td>
              <td>{user.phone}</td>
              <td>{user.website}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

Sorting (Server-side)

Send sort parameters to the server and reload data when sorting changes.

import { useAsyncList } from '@ark-ui/react/collection'

interface Product {
  id: number
  title: string
  price: number
  description: string
  category: string
  image: string
  rating: {
    rate: number
    count: number
  }
}

export const SortServerSide = () => {
  const list = useAsyncList<Product>({
    autoReload: true,
    async load({ sortDescriptor }) {
      const url = new URL('https://fakestoreapi.com/products')
      if (sortDescriptor) {
        const { direction } = sortDescriptor
        url.searchParams.set('sort', direction === 'ascending' ? 'asc' : 'desc')
      }
      const response = await fetch(url)
      const items = await response.json()
      return { items }
    },
  })

  const handleSort = (column: keyof Product) => {
    const currentSort = list.sortDescriptor
    let direction: 'ascending' | 'descending' = 'ascending'
    if (currentSort?.column === column && currentSort.direction === 'ascending') {
      direction = 'descending'
    }
    list.sort({ column, direction })
  }

  const getSortIcon = (column: keyof Product) => {
    const desc = list.sortDescriptor
    if (desc?.column !== column) return '↕️'
    return desc.direction === 'ascending' ? '↑' : '↓'
  }

  return (
    <div>
      {list.loading && <div>Loading...</div>}

      {list.error && <div>Error: {list.error.message}</div>}

      <button onClick={() => handleSort('title')}>Sort title {getSortIcon('title')}</button>

      <div>
        {list.items.map((product) => (
          <div key={product.id}>
            <div>{product.title}</div>
            <div>${product.price}</div>
            <div>{product.category}</div>
            <div>
              {product.rating.rate} ({product.rating.count} reviews)
            </div>
          </div>
        ))}
      </div>

      {list.sortDescriptor && (
        <div>
          Sorted by {list.sortDescriptor.column} ({list.sortDescriptor.direction})
        </div>
      )}
    </div>
  )
}

Dependencies

Automatically reload data when dependencies change, such as filter selections or external state.

import { useAsyncList } from '@ark-ui/react/collection'
import { useState } from 'react'

interface User {
  id: number
  name: string
  email: string
  department: string
  role: string
}

export const Dependencies = () => {
  const [selectedDepartment, setSelectedDepartment] = useState<string>('')
  const [selectedRole, setSelectedRole] = useState<string>('')

  const list = useAsyncList<User>({
    initialItems: mockUsers, // Show all users initially
    dependencies: [selectedDepartment, selectedRole],
    async load({ filterText }) {
      await delay(400) // Simulate network delay

      let items = mockUsers

      // Filter by department
      if (selectedDepartment) {
        items = items.filter((user) => user.department === selectedDepartment)
      }

      // Filter by role
      if (selectedRole) {
        items = items.filter((user) => user.role === selectedRole)
      }

      // Filter by search text
      if (filterText) {
        items = items.filter(
          (user) =>
            user.name.toLowerCase().includes(filterText.toLowerCase()) ||
            user.email.toLowerCase().includes(filterText.toLowerCase()),
        )
      }

      return { items }
    },
  })

  return (
    <div>
      <h2>Dependencies Example</h2>

      <div>
        <select value={selectedDepartment} onChange={(e) => setSelectedDepartment(e.target.value)}>
          <option value="">All Departments</option>
          {departments.map((dept) => (
            <option key={dept} value={dept}>
              {dept}
            </option>
          ))}
        </select>

        <select value={selectedRole} onChange={(e) => setSelectedRole(e.target.value)}>
          <option value="">All Roles</option>
          {roles.map((role) => (
            <option key={role} value={role}>
              {role}
            </option>
          ))}
        </select>

        <input
          type="text"
          placeholder="Search..."
          value={list.filterText}
          onChange={(e) => list.setFilterText(e.target.value)}
        />

        {list.loading && <span>Loading...</span>}
      </div>

      {list.error && <div>Error: {list.error.message}</div>}

      <div>Found {list.items.length} users</div>

      <div>
        {list.items.map((user) => (
          <div key={user.id}>
            <div>
              <strong>{user.name}</strong>
            </div>
            <div>{user.email}</div>
            <div>
              {user.department}{user.role}
            </div>
          </div>
        ))}
      </div>

      {list.items.length === 0 && !list.loading && <div>No users found with current filters</div>}
    </div>
  )
}

// Helper function to simulate API delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

const departments = ['Engineering', 'Marketing', 'Sales', 'Support']

const roles = [
  'Senior Developer',
  'Marketing Manager',
  'Frontend Developer',
  'Sales Representative',
  'DevOps Engineer',
  'Customer Success',
  'Content Creator',
  'Backend Developer',
  'Account Manager',
  'Technical Support',
  'Brand Manager',
  'Full Stack Developer',
  'Sales Director',
  'Support Manager',
  'UI Designer',
  'Digital Marketer',
  'Mobile Developer',
  'Business Development',
  'Documentation Specialist',
  'Social Media Manager',
]

const mockUsers: User[] = [
  { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering', role: 'Senior Developer' },
  { id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Marketing', role: 'Marketing Manager' },
  { id: 3, name: 'Carol Davis', email: 'carol@example.com', department: 'Engineering', role: 'Frontend Developer' },
  { id: 4, name: 'David Wilson', email: 'david@example.com', department: 'Sales', role: 'Sales Representative' },
  { id: 5, name: 'Eva Brown', email: 'eva@example.com', department: 'Engineering', role: 'DevOps Engineer' },
  { id: 6, name: 'Frank Miller', email: 'frank@example.com', department: 'Support', role: 'Customer Success' },
  { id: 7, name: 'Grace Lee', email: 'grace@example.com', department: 'Marketing', role: 'Content Creator' },
  { id: 8, name: 'Henry Taylor', email: 'henry@example.com', department: 'Engineering', role: 'Backend Developer' },
  { id: 9, name: 'Ivy Anderson', email: 'ivy@example.com', department: 'Sales', role: 'Account Manager' },
  { id: 10, name: 'Jack Thompson', email: 'jack@example.com', department: 'Support', role: 'Technical Support' },
  { id: 11, name: 'Kate Martinez', email: 'kate@example.com', department: 'Marketing', role: 'Brand Manager' },
  { id: 12, name: 'Liam Garcia', email: 'liam@example.com', department: 'Engineering', role: 'Full Stack Developer' },
  { id: 13, name: 'Mia Rodriguez', email: 'mia@example.com', department: 'Sales', role: 'Sales Director' },
  { id: 14, name: 'Noah Lopez', email: 'noah@example.com', department: 'Support', role: 'Support Manager' },
  { id: 15, name: 'Olivia White', email: 'olivia@example.com', department: 'Engineering', role: 'UI Designer' },
  { id: 16, name: 'Paul Harris', email: 'paul@example.com', department: 'Marketing', role: 'Digital Marketer' },
  { id: 17, name: 'Quinn Clark', email: 'quinn@example.com', department: 'Engineering', role: 'Mobile Developer' },
  { id: 18, name: 'Ruby Lewis', email: 'ruby@example.com', department: 'Sales', role: 'Business Development' },
  { id: 19, name: 'Sam Young', email: 'sam@example.com', department: 'Support', role: 'Documentation Specialist' },
  { id: 20, name: 'Tina Walker', email: 'tina@example.com', department: 'Marketing', role: 'Social Media Manager' },
]

API Reference

Props

  • load ((params: LoadParams<C>) => Promise<LoadResult<T, C>>) - Function to load data asynchronously
  • sort ((params: SortParams<T>) => Promise<SortResult<T>> | SortResult<T>) - Optional function for client-side sorting
  • autoReload (boolean, default: false) - Whether to automatically reload data on mount
  • initialItems (T[], default: []) - Initial items to display before first load
  • dependencies (any[], default: []) - Values that trigger a reload when changed
  • initialFilterText (string, default: '') - Initial filter text value
  • initialSortDescriptor (SortDescriptor | null) - Initial sort configuration

Load Parameters

The load function receives an object with the following properties:

  • cursor (C | undefined) - Current cursor for pagination
  • filterText (string) - Current filter text
  • sortDescriptor (SortDescriptor | null) - Current sort configuration
  • signal (AbortSignal) - AbortController signal for request cancellation

Load Result

The load function should return an object with:

  • items (T[]) - The loaded items
  • cursor (C | undefined) - Optional cursor for next page

Sort Parameters

The sort function receives an object with:

  • items (T[]) - Current items to sort
  • descriptor (SortDescriptor) - Sort configuration with column and direction

Return Value

The hook returns an object with the following properties and methods:

State Properties

  • items (T[]) - Current list of items
  • loading (boolean) - Whether a load operation is in progress
  • error (Error | null) - Any error from the last operation
  • cursor (C | undefined) - Current cursor for pagination
  • filterText (string) - Current filter text
  • sortDescriptor (SortDescriptor | null) - Current sort configuration

Methods

  • reload (() => void) - Reload data from the beginning
  • loadMore (() => void) - Load more items (when cursor is available)
  • setFilterText ((text: string) => void) - Set filter text and reload
  • sort ((descriptor: SortDescriptor) => void) - Apply sorting

Types

interface SortDescriptor {
  column: string
  direction: 'ascending' | 'descending'
}

interface LoadParams<C> {
  cursor?: C
  filterText: string
  sortDescriptor?: SortDescriptor | null
  signal: AbortSignal
}

interface LoadResult<T, C> {
  items: T[]
  cursor?: C
}