
Fernando ArturiMost Vue projects hit the same wall sooner or later: a plain <table> stops being enough. You...
Most Vue projects hit the same wall sooner or later: a plain <table> stops being enough. You need sorting, a search box, per-column filters, grouping with totals, inline editing with validation, an export button, and eventually a table too big to load at once.
I've been building inandu-grid-vue, a Vue 3 data grid that covers that list without an enterprise tier. It's MIT-licensed, ships as a single component plus a headless composable, and is about 19 KB gzipped (JS) plus 2 KB of CSS, with vue as its only peer dependency. This post walks through how it works, with code you can paste.
@inandu-solutions/grid-vue
npm install @inandu-solutions/grid-vue
<script setup lang="ts">
import { InanduGrid, type InanduGridColumn } from '@inandu-solutions/grid-vue';
import '@inandu-solutions/grid-vue/style.css';
const rows = [{ name: 'Ada', age: 36 }, { name: 'Alan', age: 41 }];
const columns: InanduGridColumn[] = [
{ field: 'name', headerText: 'Name' },
{ field: 'age', headerText: 'Age', type: 'number' },
];
</script>
<template>
<!-- sorting + filtering are on by default -->
<InanduGrid :rows="rows" :columns="columns" :page-size="25" />
</template>
Columns are plain objects. type (string, number, date, boolean) decides the filter control, the formatting and the sort rule, so a number column gets a min/max filter and a date column gets a from/to range without extra code. The default stylesheet is optional; skip the import and bring your own CSS if you prefer.
Declare an aggregate on a column and pick a column in the grid's group-by control. You get per-group sum / avg / min / max / count and a grand total row:
const columns: InanduGridColumn[] = [
{ field: 'team', headerText: 'Team' },
{ field: 'salary', headerText: 'Salary', type: 'number', format: '1.0-0', aggregate: 'avg' },
];
Mark columns editable, add creatable / deletable, and the grid renders the edit controls and validates at save time (required, min, max, pattern, a custom validator, and asyncValidator).
The design choice I care most about: the grid never mutates rows. Events hand you the change, and you decide what to do with it (update local state, call an API, roll back):
<script setup lang="ts">
import { shallowRef } from 'vue';
import { InanduGrid, type InanduGridColumn, type InanduGridRowSave } from '@inandu-solutions/grid-vue';
const rows = shallowRef([{ id: 1, name: 'Ada', salary: 90000 }]);
const columns: InanduGridColumn[] = [
{ field: 'name', headerText: 'Name', editable: true, required: true },
{ field: 'salary', headerText: 'Salary', type: 'number', editable: true, min: 0 },
];
function onRowSave({ row, values }: InanduGridRowSave) {
rows.value = rows.value.map(r => (r === row ? { ...r, ...values } : r));
}
</script>
<template>
<InanduGrid :rows="rows" :columns="columns" deletable creatable @row-save="onRowSave" />
</template>
If you've ever fought a component that edits your array behind your back, this is the opposite. There are matching row-create, row-delete and rows-delete events.
Scoped slots replace the built-in rendering per column (#cell-<field>, #header-<field>, #editor-<field>), and a #detail slot adds an expandable master-detail row. Sorting, resizing and validation keep working:
<InanduGrid :rows="orders" :columns="columns">
<template #cell-status="{ value }">
<StatusBadge :status="value" />
</template>
<template #detail="{ row }">
<OrderLines :order="row" />
</template>
</InanduGrid>
If you'd rather work in render functions, each column also accepts renderCell, renderHeader and renderEditor.
Pagination is the default, but for long lists you can render only what's visible:
<InanduGrid
:rows="tenThousandRows"
:columns="columns"
virtual-scroll
:virtual-row-height="40"
:height="400"
/>
You give it a fixed row height and a viewport height. The row height isn't auto-measured, which keeps the scroll math simple and predictable. Sorting, search and filters still work over the full set.
When the table is too big to load, let the backend sort, filter and page while the UI stays the same. useInanduGridServer turns the grid's state into a query string, aborts requests that have been superseded, debounces typing, and keeps the previous page on screen while the next one loads:
<script setup lang="ts">
import { InanduGrid, useInanduGridServer } from '@inandu-solutions/grid-vue';
const server = useInanduGridServer({ url: '/api/products' });
</script>
<template>
<InanduGrid
:rows="server.rows.value"
:columns="columns"
:page-size="25"
:server-side="{ totalItems: server.total.value }"
@query-change="server.setQuery"
/>
</template>
The request looks like /api/products?page=2&pageSize=25&sort=-price&q=cable&price_gte=20 and the response is { data: [...], total: n }. The request contract is plain query-string parameters, so any backend can implement it. If yours is ASP.NET Core with EF Core, there's a companion package that does it in one call, but nothing on the Vue side depends on it.
If you want your own markup, useInanduGrid() gives you the whole engine without any DOM. Pass a getter so it follows reactive data; every piece of state is a reactive getter, so there's no .value:
const headless = useInanduGrid(() => ({ rows: rows.value, columns, pageSize: 25 }));
headless.visibleRows; // the current page, already sorted and filtered
headless.toggleSort('name'); // drive it from your own UI
The component itself exposes the same engine on a template ref (gridRef.value?.grid.toggleSort('name')) for programmatic control.
Briefly, because it's all in the README:
.xls) and PDF, plus print, from one exportable flag. jspdf is loaded lazily, so you only pay for the PDF path if someone uses it.cell-range-selection, multi-range), and clipboard copy/paste as TSV.tree-children-key at the property holding each row's children.lang="es".InanduGridColumn, InanduGridRowSave, ...).The framework-agnostic core (sorting, filtering, aggregation, export, i18n) is ported from an Angular grid, @inandu-solutions/grid-angular, and kept in sync by hand across the Angular, React and Vue repos, so a fix to the core lands in all three. The Vue layer is where the Vue-specific parts live: the component, SFC slots, emitted events and the composables.
src/App.vue.npm install @inandu-solutions/grid-vue, then see the README for the full API.It's MIT, issues and PRs are welcome on GitHub, and I'd like to hear which feature you hit first that the grid doesn't cover.