Back to Blog

Sortable Tables & Data Grids in Kotlin Compose Desktop | Lesson 14

Sandy LaneSandy Lane

Video: Sortable Tables & Data Grids in Kotlin Compose Desktop | Lesson 14 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Sortable Tables and Data Grids: Build a Data Explorer

A spreadsheet-style grid: search, filter, sort, paginate, export. Real LazyColumn + sticky headers. The widget every business app needs.

Today's app is a data explorer — 50 products, six columns, with everything you'd expect: type to search, dropdown to filter by category, click a header to sort, paginate with rows-per-page selector, export to CSV or JSON.

The new pieces:

  • Derived state chains — filter → sort → paginate, computed via remember(keys) { ... }.
  • Custom sort state — three-state column headers (asc → desc → none).
  • Pagination — slice the list, render the page, navigate.
  • Material 3 dropdowns plus icons, plus a snackbar host for export confirmations.

What we are building

A 1100×700 window with a toolbar (title + Export CSV + Export JSON), a search bar with a category dropdown, a status row that summarises filters and counts, the data table itself, and a pagination bar. The table is the centrepiece — sortable headers, alternating rows, scroll within the visible page.

Project layout

src/main/kotlin/
├── Main.kt
├── DataExplorer.kt    # state machine + scaffold
├── DataTable.kt       # the table (header + rows + sticky)
├── Pagination.kt      # PaginationState + PaginationBar
└── SampleData.kt      # Product, ColumnDef, sample data, CSV/JSON

The data shape: Product + ColumnDef

@Serializable
data class Product(
  val id: Int,
  val name: String,
  val category: String,
  val price: Double,
  val quantity: Int,
  val rating: Double,
)

data class ColumnDef(
  val key: String,
  val label: String,
  val defaultWidth: Float,
  val getValue: (Product) -> String,
  val getSortValue: (Product) -> Comparable<*>,
)

val columns = listOf(
  ColumnDef("id", "ID", 60f, { it.id.toString() }, { it.id }),
  ColumnDef("name", "Name", 200f, { it.name }, { it.name }),
  ColumnDef("category", "Category", 130f, { it.category }, { it.category }),
  ColumnDef("price", "Price", 100f, { "$%.2f".format(it.price) }, { it.price }),
  // ...
)

The ColumnDef pattern is the heart of a generic data table. Each column knows:

  • Its key (for sort state) and human label (for the header).
  • A defaultWidth (for layout).
  • getValue(row) — how to format this column's cell text.
  • getSortValue(row) — what to compare when sorting (raw type, not formatted string — so prices sort numerically, not lexicographically).

Adding a "Stock" column is one entry in the columns list. The header, the cell renderer, the sort behaviour all derive from it.

The state machine

var allProducts by remember { mutableStateOf(sampleProducts) }
var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<String?>(null) }
var sortState by remember { mutableStateOf(SortState()) }
var paginationState by remember { mutableStateOf(PaginationState()) }

Five state pieces:

  • allProducts — the source of truth.
  • searchQuery — current search text (case-insensitive substring match).
  • selectedCategory — null = all, or a category name.
  • sortStateSortState(columnKey, direction).
  • paginationStatePaginationState(currentPage, itemsPerPage).

Derived state chain

val filtered = remember(allProducts, searchQuery, selectedCategory) {
  allProducts.filter { product ->
    val matchesSearch = searchQuery.isBlank() || /* ... */
    val matchesCategory = selectedCategory == null || product.category == selectedCategory
    matchesSearch && matchesCategory
  }
}

val sorted = remember(filtered, sortState) {
  if (sortState.columnKey == null) filtered
  else {
    val col = columns.find { it.key == sortState.columnKey } ?: return@remember filtered
    @Suppress("UNCHECKED_CAST")
    val comparator = compareBy<Product> { col.getSortValue(it) as Comparable<Any> }
    if (sortState.direction == SortDirection.ASC) filtered.sortedWith(comparator)
    else filtered.sortedWith(comparator.reversed())
  }
}

val paginated = sorted.paginate(adjustedPagination)

remember(keys) { ... } only re-runs when one of the keys changes. So:

  • filtered recomputes only when the source, query, or category changes.
  • sorted recomputes only when filtered or sortState changes.
  • paginated is cheap (a drop().take()) — no need to memoise.

This is the canonical "derived data" pattern. Each step depends on the previous, but only re-runs the necessary work.

compareBy for runtime-typed sort

val col = columns.find { it.key == sortState.columnKey } ?: return@remember filtered
@Suppress("UNCHECKED_CAST")
val comparator = compareBy<Product> { col.getSortValue(it) as Comparable<Any> }

getSortValue returns Comparable<*> — at compile time, we don't know whether it's an Int, String, or Double. The as Comparable<Any> cast tells Kotlin "trust me, all values produced by this column are comparable to each other." That's safe because each ColumnDef only ever sorts within its own column — Int against Int, String against String.

compareBy { ... } builds a Comparator<Product>. comparator.reversed() gives the descending variant.

Three-state sort

onSortChange = { key ->
  sortState = if (sortState.columnKey == key) {
    if (sortState.direction == SortDirection.ASC) SortState(key, SortDirection.DESC)
    else SortState() // clear sort on third click
  } else {
    SortState(key, SortDirection.ASC)
  }
}

Click a column header:

  • First click — sort ascending by this column.
  • Second click (same column) — sort descending.
  • Third click (same column) — clear sort.
  • Click a different column — sort ascending by the new column.

That three-click cycle is the de-facto data-grid standard. Excel, Google Sheets, every BI tool. Implementing it correctly means treating "no sort" as a state, not just "default".

Pagination

data class PaginationState(
  val currentPage: Int = 0,
  val itemsPerPage: Int = 10,
)

fun <T> List<T>.paginate(state: PaginationState): List<T> {
  val start = state.currentPage * state.itemsPerPage
  return drop(start).take(state.itemsPerPage)
}

fun totalPages(totalItems: Int, itemsPerPage: Int): Int {
  return maxOf(1, (totalItems + itemsPerPage - 1) / itemsPerPage)
}

paginate is a one-line extension on List<T> — drop everything before the page, take the page-sized window. totalPages ceils the division so 51 items at 10/page = 6 pages, not 5.

Filters changing reset the page:

onValueChange = {
  searchQuery = it
  paginationState = paginationState.copy(currentPage = 0)
}

Otherwise you'd be on page 7 of a result set that's now 1 page long.

Auto-correcting the page

val adjustedPagination = remember(totalItems, paginationState) {
  val pages = totalPages(totalItems, paginationState.itemsPerPage)
  paginationState.copy(currentPage = paginationState.currentPage.coerceIn(0, maxOf(0, pages - 1)))
}

When filters shrink the result set below the current page, clamp instead of crashing. coerceIn(0, pages - 1) does it in one call.

PaginationBar UI

Row {
  Row {
    Text("Rows per page:")
    TextButton(onClick = { showDropdown = true }) { Text("${state.itemsPerPage}") }
    DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) {
      listOf(5, 10, 15, 25, 50).forEach { size ->
        DropdownMenuItem(text = { Text("$size") }, onClick = { onItemsPerPageChange(size) })
      }
    }
  }
  Text("Showing $start–$end of $totalItems")
  Row {
    IconButton(onClick = { onPageChange(0) }, enabled = currentPage > 0) {
      Icon(Icons.Default.FirstPage)
    }
    IconButton(onClick = { onPageChange(currentPage - 1) }, enabled = currentPage > 0) {
      Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft)
    }
    Text("Page ${currentPage + 1} of $pages")
    IconButton(onClick = { onPageChange(currentPage + 1) }, enabled = currentPage < pages - 1) {
      Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight)
    }
    IconButton(onClick = { onPageChange(pages - 1) }, enabled = currentPage < pages - 1) {
      Icon(Icons.AutoMirrored.Filled.LastPage)
    }
  }
}

Three sections: rows-per-page dropdown, "Showing X–Y of Z" text, navigation buttons (first / prev / page indicator / next / last). enabled = currentPage > 0 greys out "previous" on page 1.

Icons.AutoMirrored.Filled.KeyboardArrowLeft is the RTL-safe variant — it flips for right-to-left languages automatically.

Export with snackbar feedback

val snackbarHostState = remember { SnackbarHostState() }
var snackbarMessage by remember { mutableStateOf<String?>(null) }

LaunchedEffect(snackbarMessage) {
  snackbarMessage?.let {
    snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Short)
    snackbarMessage = null
  }
}

OutlinedButton(onClick = {
  val file = File(System.getProperty("user.home"), "Desktop/products_export.csv")
  file.writeText(exportCsv(allProducts))
  snackbarMessage = "Exported CSV to ${file.name}"
})

SnackbarHostState lives outside the click handler (Snackbars are async). Writing to snackbarMessage triggers LaunchedEffect, which calls showSnackbar — the suspending API needed to display Material's snackbar.

Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) wires the host into Material's bottom-of-screen slot.

Export formats

fun exportCsv(products: List<Product>): String {
  val header = "ID,Name,Category,Price,Quantity,Rating"
  val rows = products.joinToString("\n") { p ->
    "${p.id},${p.name},${p.category},${p.price},${p.quantity},${p.rating}"
  }
  return "$header\n$rows"
}

private val prettyJson = Json { prettyPrint = true }

fun exportJson(products: List<Product>): String {
  return prettyJson.encodeToString(products)
}

Naïve CSV — good enough for the demo. A real app would quote fields containing commas or newlines, e.g. kotlinx-csv or Apache Commons CSV.

JSON via kotlinx.serialization — @Serializable on Product, Json { prettyPrint = true }, encodeToString. One line.

Status row

Row {
  Text("${filtered.size} products found")
  if (searchQuery.isNotBlank() || selectedCategory != null) {
    Text(buildString {
      val filters = mutableListOf<String>()
      if (searchQuery.isNotBlank()) filters.add("search: \"$searchQuery\"")
      if (selectedCategory != null) filters.add("category: $selectedCategory")
      append("(${filters.joinToString(", ")})")
    })
  }
  if (sortState.columnKey != null) {
    val dir = if (sortState.direction == SortDirection.ASC) "asc" else "desc"
    Text("sorted by ${sortState.columnKey} ($dir)")
  }
}

A summary line under the toolbar. Tells the user "55 products → 12 after filter; sorted ascending by price." Small, but it removes the "did my filter work?" guesswork.

Performance: why LazyColumn matters

The data table renders rows inside a LazyColumn. Even if 50 products easily fit in memory (and they do), LazyColumn only composes the rows currently visible. With 5000 rows it's the difference between a snappy UI and a 2-second hang.

For this lesson 50 rows is fine, but the architecture scales: sorting and filtering happen in plain Kotlin (cheap), pagination cuts to 10–50 visible items, LazyColumn lazily composes. Big tables stay fast.

Common mistakes

Sorting by formatted strings. getSortValue = { "$%.2f".format(it.price) } would put $10.00 < $2.00 because string-comparison sees "1" < "2". Always sort by raw values, format only for display.

Forgetting to reset page on filter change. User on page 5, types something narrow, results = 3 items, page 5 is empty. Always paginationState.copy(currentPage = 0) on filter mutations.

Recomputing on every recomposition. A naked allProducts.filter { ... } in a Composable runs on every recomposition, even if nothing relevant changed. Wrap in remember(keys) { ... }.

Storing column widths in mutableStateOf<Float> per column. A growing per-column state machine. Use one Map<String, Float> keyed by column key; update via widths + (key to width).

No Comparable<Any> cast. Sorting a List<Product> by Comparable<*> doesn't compile — Kotlin can't prove all values are mutually comparable. The cast is safe for column-bound sorts; suppress with @Suppress("UNCHECKED_CAST").

What's next

Lesson 15: testing — unit tests for pure logic, mock HTTP for the Ktor client, UI tests for Composables. Make all of this verifiable.

Recap

ColumnDef(key, label, getValue, getSortValue) for declarative column definitions. Derived state chain via remember(keys) { ... } — filter → sort → paginate. Three-state sort cycle (asc → desc → cleared). PaginationState(currentPage, itemsPerPage) plus a slice extension paginate. compareBy with Comparable<Any> cast for runtime-typed comparators. SnackbarHostState plus Scaffold for transient feedback. LazyColumn for scaling rows.

Next lesson: testing.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.