Row Key Management in Vanilla-Grid
This document explains how Vanilla-Grid resolves a stable, unique key for each data row. A correct key strategy is essential for:
- Preserving selection state across sort, scroll, and data reload cycles
- Accurate
getSelectedRows()/getSelectedKeys()results - Correct sticky select-all behavior during infinite scroll
- Efficient O(1) row lookup via the internal
_keyToRowmap
1. Design Principles
- All key strategies are opt-in. No implicit field name (e.g.
'id') is assumed. If the developer configures nothing, a per-row UUID fallback is generated and a one-time console warning is emitted. - Hidden columns still participate. Key-column detection scans
_allColumns(the full column set), not just the visible subset. Hiding a key column does not break row identity. - Keys are always strings. Whatever the source value, it is coerced to
String(value)before storage.nullandundefinedvalues are rejected (the next strategy in the chain is tried).
2. Resolution Precedence
resolveRowKey(row) evaluates these strategies in order, stopping at the first that produces a non-null value:
| Priority | Strategy | Source | When to use |
|---|---|---|---|
| 1 | rowKeyGetter(row) |
selection.rowKeyGetter option or property |
Full control: compound keys, computed keys, transformations |
| 2 | Key-column field | Column with is-key-column="true" (or equivalent marker) |
Declarative HTML: the most common case |
| 3 | rowKeyField |
selection.rowKeyField option or row-key-field attribute |
Explicit field name shortcut when no declarative column is available |
| 4 | Generated fallback | Internal Symbol-keyed UUID cached on the row object |
Emergency fallback — triggers a console warning |
2.1 rowKeyGetter (priority 1)
A function (row) => string | number | null passed at grid initialization:
el.initializeGrid({
selection: {
mode: 'multiple',
rowKeyGetter: (row) => row.rowGuid
}
});
If the function returns null or undefined, resolution falls through to priority 2.
2.2 Key-column detection (priority 2)
During setColumns(...), the grid scans all columns for any of these markers:
isKeyColumnkeyColumnis-key-columnkey-column
Accepted truthy values: true, 'true', '1', '' (empty string from a bare HTML attribute).
The field name of the first matched column is stored as _selectionKeyColumnField. If multiple columns carry the marker, the first is used and a warning is logged.
Declarative example:
<vn-grid id="myGrid">
<vn-grid-column field="Id" header="Row ID" type="number" is-key-column="true"></vn-grid-column>
<vn-grid-column field="Name" header="Name" type="string"></vn-grid-column>
</vn-grid>
Even if the Id column is later hidden by the user (via context menu or hideColumn('Id')), it remains the key column because the scan operates on _allColumns.
2.3 rowKeyField (priority 3)
A plain string naming the property to read from each row object:
el.initializeGrid({
selection: {
mode: 'multiple',
rowKeyField: 'rowId'
}
});
Or via HTML attribute:
<vn-grid row-key-field="rowId">...</vn-grid>
Default value is null — no field is assumed. This avoids silent mismatches where a conventional name like 'id' happens to match some data but not others (e.g. OData PascalCase Id vs REST camelCase id).
2.4 Generated fallback (priority 4)
When all three named strategies fail, the grid generates a UUID-like key per row:
vgk_a1b2c3d4-e5f6-...
The key is cached on the row object via a non-enumerable Symbol property (invisible to JSON.stringify, Object.keys, etc.). A one-time console.warn is emitted:
[VanillaGrid] No stable row key found using rowKeyGetter, key column, or rowKeyField. Generating fallback UUID per row. Consider providing one of these key strategies.
Generated keys are not stable across data reloads — selecting rows, reloading data, and expecting the same selection to persist will fail. This is intentional: the warning tells the developer to configure a real key strategy.
3. Compound (Composite) Keys
Some data models have no single unique column — identity is defined by a combination of fields. The rowKeyGetter function handles this naturally.
3.1 Basic compound key
el.initializeGrid({
selection: {
mode: 'multiple',
rowKeyGetter: (row) => `${row.CompanyId}::${row.OrderId}`
}
});
The separator (::) should be a string that cannot appear in either field value. Common choices:
::— safe for numeric/UUID fields\x00(null byte) — guaranteed safe for any printable string value|— readable but risky if field values contain pipes
3.2 Three-part key
rowKeyGetter: (row) => [row.Region, row.Year, row.ProductCode].join('::')
3.3 Nested/expanded property key
For OData responses with $expand, the key might come from a nested object:
// Row shape: { Order: { Id: 42 }, LineNumber: 3 }
rowKeyGetter: (row) => `${row.Order?.Id}::${row.LineNumber}`
3.4 Key from an array index or computed value
// Use a hash of multiple fields when no single natural key exists
rowKeyGetter: (row) => {
const parts = [row.Timestamp, row.SensorId, row.ReadingType];
return parts.join('::');
}
3.5 Defensive null handling
If any component of a compound key can be null, the getter should return null to let the fallback chain continue (or handle it explicitly):
rowKeyGetter: (row) => {
if (row.CompanyId == null || row.OrderId == null) return null;
return `${row.CompanyId}::${row.OrderId}`;
}
4. OData and PascalCase Considerations
OData V4 backends (e.g. ASP.NET Core with ODataConventionModelBuilder) typically return PascalCase property names (Id, Name, CompanyId), while traditional REST APIs use camelCase (id, name, companyId).
This matters because:
rowKeyField: 'id'will not match a PascalCaseIdproperty — JavaScript property access is case-sensitive.is-key-column="true"on a<vn-grid-column field="Id">works correctly because the column'sfield/keyattribute matches the actual property name.
Recommendation: Prefer the declarative is-key-column approach. It naturally aligns with the column's field attribute, which must already match the data property name for the grid to display data correctly. No casing mismatch is possible.
If using rowKeyField, ensure the casing matches the backend response exactly:
// OData backend → PascalCase
rowKeyField: 'Id'
// REST/camelCase backend
rowKeyField: 'id'
5. When to Use Each Strategy
| Scenario | Recommended strategy |
|---|---|
| Single natural key column exists | is-key-column="true" on the <vn-grid-column> |
| Key column not declared in HTML (programmatic columns) | selection: { rowKeyField: 'fieldName' } |
| Compound/composite key | selection: { rowKeyGetter: (row) => ... } |
| Key derived from nested/expanded property | selection: { rowKeyGetter: (row) => ... } |
| Key requires transformation (e.g. lowercase normalization) | selection: { rowKeyGetter: (row) => ... } |
| Quick prototype, no selection needed | Leave unconfigured (fallback UUIDs are fine while selection.mode is 'noselection') |
6. Duplicate Key Handling
When _rebuildKeyToRowMap(rows) encounters duplicate keys across rows:
- The last row with a given key wins the
_keyToRowentry. - A console warning is emitted listing duplicated keys and their occurrence counts.
getSelectedRows()for a duplicated key returns the last row — earlier rows with the same key are inaccessible.
Duplicate keys indicate a problem in the key strategy, not in the grid. Common causes:
- Missing or wrong
rowKeyGetter/rowKeyFieldthat maps to a non-unique field - Backend returning duplicate records (data quality issue)
7. Performance Notes
- Key resolution runs once per row during
_rebuildKeyToRowMap(), which is called onsetRows()andappendRows(). rowKeyGetteris the most flexible but introduces a function call per row. For 100k rows this is negligible (<5ms on modern hardware).- The generated fallback (priority 4) uses
crypto.randomUUID()when available, falling back toMath.random().toString(36). Both are fast. - The internal
_keyToRowmap provides O(1) lookup forgetSelectedRows()and selection state checks.
8. Summary
resolveRowKey(row)
│
├── 1. rowKeyGetter(row) → non-null? → use it ✓
│
├── 2. row[_selectionKeyColumnField] → non-null? → use it ✓
│ (from is-key-column on _allColumns, including hidden)
│
├── 3. row[rowKeyField] → non-null? → use it ✓
│ (explicit field name, default null)
│
└── 4. Generate UUID fallback + warn once ⚠
Best practice: Always configure at least one key strategy when selection is enabled. The declarative is-key-column approach is the simplest and most robust for single-key scenarios. Use rowKeyGetter for compound keys or any non-trivial key derivation logic.