Sortable Tables & Data Grids in Kotlin Compose Desktop | Lesson 14
Video: Sortable Tables & Data Grids in Kotlin Compose Desktop | Lesson 14 by Taught by Celeste AI - AI Coding Coach
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 humanlabel(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.sortState—SortState(columnKey, direction).paginationState—PaginationState(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:
filteredrecomputes only when the source, query, or category changes.sortedrecomputes only whenfilteredorsortStatechanges.paginatedis cheap (adrop().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.