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:


1. Design Principles

  1. 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.
  2. 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.
  3. Keys are always strings. Whatever the source value, it is coerced to String(value) before storage. null and undefined values 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:

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:

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:

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:

Duplicate keys indicate a problem in the key strategy, not in the grid. Common causes:


7. Performance Notes


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.