How to Build a Flutter Table UI for Displaying Structured Data

Use DataTable for interactive grids with sorting and selection, PaginatedDataTable for large datasets with built-in pagination, or the lower-level Table widget for fully custom static layouts.

Flutter provides several widgets for building table-like interfaces in the flutter/flutter repository. Whether you need a simple data grid or a complex interactive table, understanding the core implementations in packages/flutter/lib/src/material/data_table.dart and packages/flutter/lib/src/widgets/table.dart helps you choose the right approach for your use case.

Choosing the Right Flutter Table Widget

Flutter offers three primary approaches for table UIs:

  • DataTable – Best for small to medium datasets that require column headers, sorting, row selection, and optional checkboxes. It is a StatelessWidget that internally builds a Material → Table tree, as seen in the build method at lines 1025–1054 of data_table.dart.
  • PaginatedDataTable – Extends DataTable with pagination controls and works with DataTableSource to avoid loading all rows at once.
  • Table – A lower-level layout widget in packages/flutter/lib/src/widgets/table.dart that gives you full control over column widths and borders, backed by RenderTable in packages/flutter/lib/src/rendering/table.dart.

Building an Interactive DataTable with Sorting and Selection

The DataTable widget provides a complete implementation for interactive data grids. According to the source code in packages/flutter/lib/src/material/data_table.dart, it composes a Table widget with specific TableColumnWidth configurations and manages row selection state internally.

Architecture and Source Code

In DataTable.build, the widget constructs TableRow objects for the header and each data row, then passes them to a Table widget. The column definitions use DataColumn (lines 38–51), which holds the header widget, optional tooltip, and sorting callback onSort. Every data row is a DataRow (lines 44–61) containing DataCell widgets (lines 84–103) that wrap the actual content and provide interaction callbacks like onTap.

When sorting is enabled via DataColumn.onSort, the header displays an animated arrow managed by the private _SortArrow class (lines 1289–1320). Row selection automatically adds a leading checkbox column when any DataRow provides an onSelectChanged callback; the header checkbox state is managed in _handleSelectAll (lines 803–822).

Styling is resolved through DataTableTheme or fallback defaults using WidgetStateProperty.resolveWith (lines 1036–1062).

Complete Example

The following example demonstrates sorting, row selection, and the "select all" checkbox:

class SimpleDataTable extends StatefulWidget {
  const SimpleDataTable({super.key});

  @override
  State<SimpleDataTable> createState() => _SimpleDataTableState();
}

class _SimpleDataTableState extends State<SimpleDataTable> {
  int? _sortColumn;
  bool _ascending = true;
  final List<_Person> _people = [
    _Person('Alice', 30),
    _Person('Bob', 24),
    _Person('Claire', 28),
  ];
  final Set<_Person> _selected = {};

  void _sort<T>(Comparable<T> Function(_Person p) getField, int columnIndex) {
    setState(() {
      _sortColumn = columnIndex;
      _ascending = !_ascending;
      _people.sort((a, b) {
        final aVal = getField(a);
        final bVal = getField(b);
        return _ascending
            ? Comparable.compare(aVal, bVal)
            : Comparable.compare(bVal, aVal);
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return DataTable(
      sortColumnIndex: _sortColumn,
      sortAscending: _ascending,
      onSelectAll: (checked) {
        setState(() {
          if (checked ?? false) {
            _selected.addAll(_people);
          } else {
            _selected.clear();
          }
        });
      },
      columns: [
        DataColumn(
          label: const Text('Name'),
          onSort: (i, _) => _sort((p) => p.name, i),
        ),
        DataColumn(
          label: const Text('Age'),
          numeric: true,
          onSort: (i, _) => _sort((p) => p.age, i),
        ),
      ],
      rows: _people.map((person) {
        final selected = _selected.contains(person);
        return DataRow(
          selected: selected,
          onSelectChanged: (v) {
            setState(() {
              v! ? _selected.add(person) : _selected.remove(person);
            });
          },
          cells: [
            DataCell(Text(person.name)),
            DataCell(Text(person.age.toString())),
          ],
        );
      }).toList(),
    );
  }
}

class _Person {
  final String name;
  final int age;
  _Person(this.name, this.age);
}

Handling Large Datasets with PaginatedDataTable

For datasets with hundreds or thousands of rows, use PaginatedDataTable to avoid loading all widgets into memory at once. This widget extends DataTable with pagination controls and requires a DataTableSource implementation to lazily provide rows.

The following example demonstrates a paginated table with 200 rows:

class LargeDataTable extends StatefulWidget {
  const LargeDataTable({super.key});

  @override
  State<LargeDataTable> createState() => _LargeDataTableState();
}

class _LargeDataTableState extends State<LargeDataTable> {
  final _source = _MyDataSource();

  @override
  Widget build(BuildContext context) {
    return PaginatedDataTable(
      header: const Text('Users'),
      rowsPerPage: 8,
      columns: const [
        DataColumn(label: Text('ID')),
        DataColumn(label: Text('Email')),
        DataColumn(label: Text('Role')),
      ],
      source: _source,
    );
  }
}

class _MyDataSource extends DataTableSource {
  final List<_User> _users = List.generate(
    200,
    (i) => _User(i, 'user$i@example.com', i % 2 == 0 ? 'Admin' : 'User'),
  );

  @override
  DataRow? getRow(int index) {
    if (index >= _users.length) return null;
    final user = _users[index];
    return DataRow.byIndex(
      index: index,
      cells: [
        DataCell(Text(user.id.toString())),
        DataCell(Text(user.email)),
        DataCell(Text(user.role)),
      ],
    );
  }

  @override bool get isRowCountApproximate => false;
  @override int get rowCount => _users.length;
  @override int get selectedRowCount => 0;
}

class _User {
  final int id;
  final String email;
  final String role;
  _User(this.id, this.email, this.role);
}

Creating Custom Layouts with the Table Widget

For scenarios requiring precise control over column widths, borders, and cell content without Material Design styling, use the lower-level Table widget from packages/flutter/lib/src/widgets/table.dart. This widget is the foundation that DataTable uses internally, backed by RenderTable in packages/flutter/lib/src/rendering/table.dart.

The following example demonstrates a static table with custom column widths:

class SimpleTable extends StatelessWidget {
  const SimpleTable({super.key});

  @override
  Widget build(BuildContext context) {
    return Table(
      border: TableBorder.all(),
      columnWidths: const {
        0: FixedColumnWidth(120),
        1: IntrinsicColumnWidth(),
        2: FlexColumnWidth(),
      },
      children: [
        TableRow(
          decoration: const BoxDecoration(color: Colors.grey),
          children: const [
            Padding(padding: EdgeInsets.all(8), child: Text('ID', style: TextStyle(fontWeight: FontWeight.bold))),
            Padding(padding: EdgeInsets.all(8), child: Text('Name', style: TextStyle(fontWeight: FontWeight.bold))),
            Padding(padding: EdgeInsets.all(8), child: Text('Score', style: TextStyle(fontWeight: FontWeight.bold))),
          ],
        ),
        const TableRow(
          children: [
            Padding(padding: EdgeInsets.all(8), child: Text('1')),
            Padding(padding: EdgeInsets.all(8), child: Text('Alice')),
            Padding(padding: EdgeInsets.all(8), child: Text('42')),
          ],
        ),
        const TableRow(
          children: [
            Padding(padding: EdgeInsets.all(8), child: Text('2')),
            Padding(padding: EdgeInsets.all(8), child: Text('Bob')),
            Padding(padding: EdgeInsets.all(8), child: Text('37')),
          ],
        ),
      ],
    );
  }
}

Summary

  • DataTable in packages/flutter/lib/src/material/data_table.dart provides a complete Material Design table with sorting, selection, and theming support via DataColumn, DataRow, and DataCell.
  • Row selection automatically adds a checkbox column when onSelectChanged is provided, managed internally by _handleSelectAll (lines 803–822).
  • Sorting displays an animated _SortArrow (lines 1289–1320) when DataColumn.onSort is defined.
  • PaginatedDataTable extends DataTable with pagination controls and lazy loading through DataTableSource for large datasets.
  • Table in packages/flutter/lib/src/widgets/table.dart offers low-level layout control via RenderTable, ideal for static or highly custom grids.
  • For massive or infinite datasets, use TableView from the two_dimensional_scrollables package to enable virtual scrolling and avoid layout costs.

Frequently Asked Questions

What is the difference between DataTable and Table in Flutter?

DataTable is a high-level Material Design widget that provides built-in support for column headers, sorting, row selection, and optional checkboxes. It is implemented in packages/flutter/lib/src/material/data_table.dart as a StatelessWidget that internally builds a Table widget. The Table widget, found in packages/flutter/lib/src/widgets/table.dart, is a lower-level layout primitive backed by RenderTable that gives you full control over column widths and cell content but requires manual implementation of sorting, selection, and other interactive features.

How do I add sorting to a Flutter DataTable?

To enable sorting, provide an onSort callback to the DataColumn constructor. The callback receives the column index and a boolean indicating whether the sort should be ascending. In your DataTable build method, set sortColumnIndex to the active column and sortAscending to the current direction. When onSort is provided, DataTable automatically displays an animated arrow in the header cell managed by the private _SortArrow class (lines 1289–1320 in data_table.dart).

What is the best way to display large datasets in a Flutter table?

For datasets with hundreds or thousands of rows, use PaginatedDataTable instead of DataTable. PaginatedDataTable adds pagination controls and works with a DataTableSource to lazily provide only the rows needed for the current page, avoiding the memory and layout cost of instantiating all widgets upfront. If you need to display massive or infinite datasets without pagination, use the TableView widget from the two_dimensional_scrollables package, which provides virtual scrolling and only renders visible cells.

How do I customize column widths in a Flutter table?

For DataTable, you can provide a columnWidth property in DataColumn or wrap the table in a DataTableTheme to apply consistent sizing across your app. For complete control, use the lower-level Table widget and specify the columnWidths map, which accepts TableColumnWidth subclasses such as FixedColumnWidth, FlexColumnWidth, IntrinsicColumnWidth, or FractionColumnWidth. The Table widget uses RenderTable (defined in packages/flutter/lib/src/rendering/table.dart) to apply these constraints during the layout phase.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →