Collection components

Many components display a collection of items, and provide functionality such as keyboard navigation, selection, and more. React Spectrum has a unified API to define the items displayed in these components.

Introduction#


There are many components that display a collection of items of some kind. For example, lists, menus, selects, tables, trees, and grids. These collections can usually be navigated with the keyboard using arrow keys, and many have some form of selection. Many support loading data asynchronously, updating that data over time, virtualized scrolling for performance with large collections, and more.

There are many ways one could design an API for components like this: JSX children, a list of option objects, or a datasource object. Selection, and other states like disabled, could be passed in to each item, or as a top-level prop. Each of these has various tradeoffs. This page describes how we do it in React Spectrum, and covers some of these tradeoffs in detail.

Design goals#


Our main design goal was to provide a consistent API across many types of collection components that is easy to learn, performant on large collections, and extensible for advanced features. Some of the use-cases we considered were:

  • Static data
  • Dynamic data
  • Async loading
  • Sections/groups of data from a single source
  • Sections from different sources
  • Single and multiple selection (controlled and uncontrolled)
  • Controlled and uncontrolled expanded states for components like trees and accordions
  • Marking items as selected, expanded, disabled, etc. prior to loading them from a server
  • Drag and drop of items
  • Virtualization for large collections
  • Infinite scrolling to load more items on demand

Static collections#


React Spectrum implements a JSX-based API for defining collections. This allows an intuitive way to provide items, which can contain rich rendering, and various options as props. Building hierarchical collections, e.g. sections, or a tree of items is also very natural in JSX.

A static collection is a collection that does not change over time (e.g. hard coded). This is common for components like action menus where the items are built into the application rather than representing user data.

A simple static collection could be constructed as in the following example.

<Menu>
  <Item>Open</Item>
  <Item>Edit</Item>
  <Item>Delete</Item>
</Menu>
<Menu>
  <Item>Open</Item>
  <Item>Edit</Item>
  <Item>Delete</Item>
</Menu>
<Menu>
  <Item>Open</Item>
  <Item>Edit</Item>
  <Item>Delete</Item>
</Menu>

Sections#

Sections or groups of items can be constructed by wrapping the items as needed.

<ListBox>
  <Section title="People">
    <Item>David</Item>
    <Item>Sam</Item>
    <Item>Jane</Item>
  </Section>
  <Section title="Animals">
    <Item>Aardvark</Item>
    <Item>Kangaroo</Item>
    <Item>Snake</Item>
  </Section>
</ListBox>
<ListBox>
  <Section title="People">
    <Item>David</Item>
    <Item>Sam</Item>
    <Item>Jane</Item>
  </Section>
  <Section title="Animals">
    <Item>Aardvark</Item>
    <Item>Kangaroo</Item>
    <Item>Snake</Item>
  </Section>
</ListBox>
<ListBox>
  <Section title="People">
    <Item>David</Item>
    <Item>Sam</Item>
    <Item>Jane</Item>
  </Section>
  <Section title="Animals">
    <Item>
      Aardvark
    </Item>
    <Item>
      Kangaroo
    </Item>
    <Item>Snake</Item>
  </Section>
</ListBox>

The <Item> and <Section> components are shared between many collection components. This ensures that they have a consistent interface that can be learned once and applied everywhere.

<Item> and <Section> only define the data for the items and sections, not the rendering, visual appearance, or behavior. This is up to the individual collection component (e.g. Menu or ListBox) to implement.

Dynamic collections#


Static collections are great when the items never changes, but how about dynamic data? A dynamic collection is a collection that is based on data, for example from an API. In addition, it may change over time as items are added, updated, or removed from the collection by a user.

React Spectrum implements a JSX-based interface for dynamic collections, which maps over your data and applies a function for each item to render it. The following example shows how a collection can be rendered based on dynamic data, stored in React state.

let [animals, setAnimals] = useState([
  {id: 1, name: 'Aardvark'},
  {id: 2, name: 'Kangaroo'},
  {id: 3, name: 'Snake'}
]);

<ListBox items={animals}>{(item) => <Item>{item.name}</Item>}</ListBox>
let [animals, setAnimals] = useState([
  {id: 1, name: 'Aardvark'},
  {id: 2, name: 'Kangaroo'},
  {id: 3, name: 'Snake'}
]);

<ListBox items={animals}>
  {(item) => <Item>{item.name}</Item>}
</ListBox>
let [
  animals,
  setAnimals
] = useState([
  {
    id: 1,
    name: 'Aardvark'
  },
  {
    id: 2,
    name: 'Kangaroo'
  },
  {id: 3, name: 'Snake'}
]);

<ListBox
  items={animals}>
  {(item) => (
    <Item>
      {item.name}
    </Item>
  )}
</ListBox>

As you can see, the items are passed to the items prop of the top-level component, which iterates over each item and calls the function passed as children to the component. The item object is passed to the function, which returns an <Item>.

Unique keys#

All items in a collection must have a unique key or id, which is used to determine what items in the collection changed when updates occur. By default, React Spectrum looks for an id or key property on each item, which is often returned from a database. You can also specify a custom key on each item using the key prop. For example, if all animals in the example had a unique name property, then each item's key could be set to item.name to use it as the unique key.

let [animals, setAnimals] = useState([
  {name: 'Aardvark'},
  {name: 'Kangaroo'},
  {name: 'Snake'}
]);

<ListBox items={animals}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</ListBox>
let [animals, setAnimals] = useState([
  {name: 'Aardvark'},
  {name: 'Kangaroo'},
  {name: 'Snake'}
]);

<ListBox items={animals}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</ListBox>
let [
  animals,
  setAnimals
] = useState([
  {name: 'Aardvark'},
  {name: 'Kangaroo'},
  {name: 'Snake'}
]);

<ListBox
  items={animals}>
  {(item) => (
    <Item
      key={item.name}>
      {item.name}
    </Item>
  )}
</ListBox>

Note that if you do specify a custom key on each Item using the key prop, React will convert the provided key to a string. As a result, all the collection component's key related event handlers and props (e.g. onSelectionChange, selectedKey(s)) will expect and return strings. If you forgo specifying a key on the Item and instead rely on the id or key being defined in your items array, the key related event handlers and props will match what you have in your items array. See the Selection docs for more info.

Why not array map?#

You may be wondering why we didn't use animals.map in this example. In fact, you can do this if you want and it will work, but it will not be as performant.

let [animals, setAnimals] = useState([
  {name: 'Aardvark'},
  {name: 'Kangaroo'},
  {name: 'Snake'}
]);

<ListBox>
  {animals.map((item) => (
    <Item key={item.name}>{item.name}</Item>
  ))}
</ListBox>
let [animals, setAnimals] = useState([
  {name: 'Aardvark'},
  {name: 'Kangaroo'},
  {name: 'Snake'}
]);

<ListBox>
  {animals.map((item) => (
    <Item key={item.name}>{item.name}</Item>
  ))}
</ListBox>
let [
  animals,
  setAnimals
] = useState([
  {name: 'Aardvark'},
  {name: 'Kangaroo'},
  {name: 'Snake'}
]);

<ListBox>
  {animals.map(
    (item) => (
      <Item
        key={
          item.name
        }>
        {item.name}
      </Item>
    )
  )}
</ListBox>

Using the items prop and providing a render function allows React Spectrum to automatically cache the results of rendering each item and avoid re-rendering all items in the collection when only one of them changes. This has big performance benefits for large collections.

Updating data#

When you need to update the data to add, remove, or change an item, you can do so using a standard React state update.

IMPORTANT: all items passed to a collection component must be immutable. Changing a property on an item, or calling array.push() or other mutating methods will not work as expected.

The useListData hook can be used to manage the data and state for a list of items, and update it over time. It will also handle removing items from the selection state when they are removed from the list. See the useListBox docs for more details.

The following example shows how you might append a new item to the list.

import {useListData} from '@react-stately/data';

let list = useListData({
  initialItems: [{name: 'Aardvark'}, {name: 'Kangaroo'}, {name: 'Snake'}],
  initialSelectedKeys: ['Kangaroo'],
  getKey: (item) => item.name
});

function addAnimal(name) {
  list.append({name});
}

<ListBox items={list.items}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</ListBox>
import {useListData} from '@react-stately/data';

let list = useListData({
  initialItems: [
    {name: 'Aardvark'},
    {name: 'Kangaroo'},
    {name: 'Snake'}
  ],
  initialSelectedKeys: ['Kangaroo'],
  getKey: (item) => item.name
});

function addAnimal(name) {
  list.append({name});
}

<ListBox items={list.items}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</ListBox>
import {useListData} from '@react-stately/data';

let list = useListData({
  initialItems: [
    {name: 'Aardvark'},
    {name: 'Kangaroo'},
    {name: 'Snake'}
  ],
  initialSelectedKeys: [
    'Kangaroo'
  ],
  getKey: (item) =>
    item.name
});

function addAnimal(
  name
) {
  list.append({name});
}

<ListBox
  items={list.items}>
  {(item) => (
    <Item
      key={item.name}>
      {item.name}
    </Item>
  )}
</ListBox>

Sections#

Sections can be built by returning a <Section> instead of an <Item> item from the top-level item renderer. Sections also support an items prop and a renderer function for their children.

let [sections, setSections] = useState([
  {
    name: 'People',
    items: [{name: 'David'}, {name: 'Same'}, {name: 'Jane'}]
  },
  {
    name: 'Animals',
    items: [{name: 'Aardvark'}, {name: 'Kangaroo'}, {name: 'Snake'}]
  }
]);

<Picker items={sections}>
  {(section) => (
    <Section key={section.name} title={section.name} items={section.items}>
      {(item) => <Item key={item.name}>{item.name}</Item>}
    </Section>
  )}
</Picker>
let [sections, setSections] = useState([
  {
    name: 'People',
    items: [{name: 'David'}, {name: 'Same'}, {name: 'Jane'}]
  },
  {
    name: 'Animals',
    items: [
      {name: 'Aardvark'},
      {name: 'Kangaroo'},
      {name: 'Snake'}
    ]
  }
]);

<Picker items={sections}>
  {(section) => (
    <Section
      key={section.name}
      title={section.name}
      items={section.items}>
      {(item) => <Item key={item.name}>{item.name}</Item>}
    </Section>
  )}
</Picker>
let [
  sections,
  setSections
] = useState([
  {
    name: 'People',
    items: [
      {name: 'David'},
      {name: 'Same'},
      {name: 'Jane'}
    ]
  },
  {
    name: 'Animals',
    items: [
      {name: 'Aardvark'},
      {name: 'Kangaroo'},
      {name: 'Snake'}
    ]
  }
]);

<Picker
  items={sections}>
  {(section) => (
    <Section
      key={
        section.name
      }
      title={
        section.name
      }
      items={
        section.items
      }>
      {(item) => (
        <Item
          key={
            item.name
          }>
          {item.name}
        </Item>
      )}
    </Section>
  )}
</Picker>

When updating nested data, be sure that all parent items change accordingly. Items are immutable, so don't use mutating methods like push, or replace a property on a parent item. Instead, copy the items that changed as needed.

The useTreeData hook can be used to manage data and state for a tree of items. This is similar to useListData, but with support for heirarchical data. Like useListData, useTreeData will also handle automatically removing items from the selection when they are removed from the list. See the useTreeData docs for more details.

import {useTreeData} from '@react-stately/data';

let tree = useTreeData({
  initialItems: [
    {
      name: 'People',
      items: [{name: 'David'}, {name: 'Sam'}, {name: 'Jane'}]
    },
    {
      name: 'Animals',
      items: [{name: 'Aardvark'}, {name: 'Kangaroo'}, {name: 'Snake'}]
    }
  ],
  getKey: (item) => item.name,
  getChildren: (item) => item.items
});

function addPerson(name) {
  tree.append('People', {name});
}

<ListBox items={tree.items}>
  {(node) => (
    <Section title={node.value.name} items={node.children}>
      {(node) => <Item>{node.value.name}</Item>}
    </Section>
  )}
</ListBox>
import {useTreeData} from '@react-stately/data';

let tree = useTreeData({
  initialItems: [
    {
      name: 'People',
      items: [
        {name: 'David'},
        {name: 'Sam'},
        {name: 'Jane'}
      ]
    },
    {
      name: 'Animals',
      items: [
        {name: 'Aardvark'},
        {name: 'Kangaroo'},
        {name: 'Snake'}
      ]
    }
  ],
  getKey: (item) => item.name,
  getChildren: (item) => item.items
});

function addPerson(name) {
  tree.append('People', {name});
}

<ListBox items={tree.items}>
  {(node) => (
    <Section
      title={node.value.name}
      items={node.children}>
      {(node) => <Item>{node.value.name}</Item>}
    </Section>
  )}
</ListBox>
import {useTreeData} from '@react-stately/data';

let tree = useTreeData({
  initialItems: [
    {
      name: 'People',
      items: [
        {name: 'David'},
        {name: 'Sam'},
        {name: 'Jane'}
      ]
    },
    {
      name: 'Animals',
      items: [
        {
          name:
            'Aardvark'
        },
        {
          name:
            'Kangaroo'
        },
        {name: 'Snake'}
      ]
    }
  ],
  getKey: (item) =>
    item.name,
  getChildren: (item) =>
    item.items
});

function addPerson(
  name
) {
  tree.append('People', {
    name
  });
}

<ListBox
  items={tree.items}>
  {(node) => (
    <Section
      title={
        node.value.name
      }
      items={
        node.children
      }>
      {(node) => (
        <Item>
          {
            node.value
              .name
          }
        </Item>
      )}
    </Section>
  )}
</ListBox>

Asynchronous loading#


Many collection components support loading data asynchronously. This is supported by passing an isLoading prop, which is true while the data is loading. Once the data is loaded, isLoading should be set to false, and the items prop should be set to the loaded data.

The useAsyncList hook from @react-stately/data can be used to manage async data loading from an API. Pass a load function to useAsyncList, which returns the items to render. You can use whatever data fetching library you want, or the built-in fetch API.

This example fetches a list of Pokemon from an API and displays them in a Picker.

import {useAsyncList} from '@react-stately/data';

let list = useAsyncList({
  async load({signal}) {
    let res = await fetch('https://pokeapi.co/api/v2/pokemon', {signal});
    let json = await res.json();
    return {items: json.results};
  }
});

<Picker label="Pick a Pokemon" items={list.items} isLoading={list.isLoading}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</Picker>
import {useAsyncList} from '@react-stately/data';

let list = useAsyncList({
  async load({signal}) {
    let res = await fetch(
      'https://pokeapi.co/api/v2/pokemon',
      {signal}
    );
    let json = await res.json();
    return {items: json.results};
  }
});

<Picker
  label="Pick a Pokemon"
  items={list.items}
  isLoading={list.isLoading}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</Picker>
import {useAsyncList} from '@react-stately/data';

let list = useAsyncList({
  async load({signal}) {
    let res = await fetch(
      'https://pokeapi.co/api/v2/pokemon',
      {signal}
    );
    let json = await res.json();
    return {
      items: json.results
    };
  }
});

<Picker
  label="Pick a Pokemon"
  items={list.items}
  isLoading={
    list.isLoading
  }>
  {(item) => (
    <Item
      key={item.name}>
      {item.name}
    </Item>
  )}
</Picker>

Infinite loading#

The useAsyncList hook also supports paginated data, which is common in many APIs to avoid loading too many items at once. This is accomplished by returning a cursor in addition to items from the load function. When the loadMore method is called, the cursor is passed back to your load function, which you can use to determine the URL for the next page. The onLoadMore prop supported by many collection components notifies you when you should load more data as the user scrolls.

This example expands on the previous one to support infinite scrolling through all known Pokemon.

import {useAsyncList} from '@react-stately/data';

let list = useAsyncList({
  async load({signal, cursor}) {
    // If no cursor is available, then we're loading the first page.
    // Otherwise, the cursor is the next URL to load, as returned from the previous page.
    let res = await fetch(cursor || 'https://pokeapi.co/api/v2/pokemon', {
      signal
    });
    let json = await res.json();
    return {
      items: json.results,
      cursor: json.next
    };
  }
});

<Picker
  label="Pick a Pokemon"
  items={list.items}
  isLoading={list.isLoading}
  onLoadMore={list.loadMore}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</Picker>
import {useAsyncList} from '@react-stately/data';

let list = useAsyncList({
  async load({signal, cursor}) {
    // If no cursor is available, then we're loading the first page.
    // Otherwise, the cursor is the next URL to load, as returned from the previous page.
    let res = await fetch(
      cursor || 'https://pokeapi.co/api/v2/pokemon',
      {signal}
    );
    let json = await res.json();
    return {
      items: json.results,
      cursor: json.next
    };
  }
});

<Picker
  label="Pick a Pokemon"
  items={list.items}
  isLoading={list.isLoading}
  onLoadMore={list.loadMore}>
  {(item) => <Item key={item.name}>{item.name}</Item>}
</Picker>
import {useAsyncList} from '@react-stately/data';

let list = useAsyncList({
  async load({
    signal,
    cursor
  }) {
    // If no cursor is available, then we're loading the first page.
    // Otherwise, the cursor is the next URL to load, as returned from the previous page.
    let res = await fetch(
      cursor ||
        'https://pokeapi.co/api/v2/pokemon',
      {signal}
    );
    let json = await res.json();
    return {
      items:
        json.results,
      cursor: json.next
    };
  }
});

<Picker
  label="Pick a Pokemon"
  items={list.items}
  isLoading={
    list.isLoading
  }
  onLoadMore={
    list.loadMore
  }>
  {(item) => (
    <Item
      key={item.name}>
      {item.name}
    </Item>
  )}
</Picker>