| Section | Description |
|---|---|
| Overview | Core classes, architecture diagram |
| Store | Creating, loading, filtering, and observing record collections |
| StoreRecord | Record state, data access, tree navigation, validation |
| Field | Type parsing, display names, descriptions, validation rules |
| Filter System | FieldFilter, CompoundFilter, FunctionFilter, and utilities |
| Validation System | Rules, constraints, severity levels, async validation |
| Integration with GridModel | Inline store config, data loading, and editing |
| Tree Data | Hierarchical loading, filtering, and summary records |
| Cube (Aggregation) | Pointer to dedicated cube/README.md |
| Performance and Memory | Record reuse, projections, streaming loads, memory tuning |
| Diagnostics | Per-operation timing and path reporting |
| Common Patterns | processRawData, composite IDs, record queries |
| Common Pitfalls | ID fields, data enumeration, mutation, record order |
The /data/ package is Hoist's data management layer - observable, in-memory data containers with
support for hierarchical structures, filtering, validation, and multi-dimensional aggregation.
The core classes are:
| Class | Purpose |
|---|---|
| Store | Observable collection of records with filtering, selection, and modification tracking |
| StoreRecord | Individual record wrapper with state tracking, validation, and tree navigation |
| Field | Metadata descriptor defining type parsing, defaults, and validation rules |
| Cube | Multi-dimensional aggregation engine for OLAP-style grouping and analysis |
| View | Query result from a Cube - hierarchical, auto-updating aggregated data |
Store, StoreRecord, and Field appear in virtually every Hoist application. Cube and View support advanced analytics use cases, where the app groups and aggregates data dynamically.
The package also includes:
- Filter system - Composable, immutable filters with JSON serialization
- Validation system - Synchronous and async constraints with multiple severity levels
- UrlStore - A
Storesubclass that loads its own data from a URL - RecordAction - Shared config for grid context menus and action columns
- StoreSelectionModel - Observable record selection, typically created and held by
GridModel
Store Cube
βββ fields: Field[] βββ fields: CubeField[]
βββ records: StoreRecord[] βββ store: Store (source data)
βββ rootRecords: StoreRecord[] βββ views: View[]
βββ summaryRecords: StoreRecord[]
βββ filter: Filter View
βββ validator: StoreValidator βββ query: Query (dimensions, filters)
βββ diagnostics: StoreDiagnostics βββ result: ViewResult (observable output)
βββ stores: Store[] (connected for auto-loading)
StoreRecord
βββ id: StoreRecordId
βββ data: PlainObject // Current field values - read by field name only
βββ committedData: PlainObject // Last committed state
βββ digest: unknown // Snapshot used to detect unchanged raw data
βββ parent / children // Tree navigation
βββ validationState // Per-record validation
Field CubeField extends Field
βββ name: string βββ aggregator: Aggregator
βββ type: FieldType βββ isLeafDimension: boolean
βββ isDimension: boolean βββ parentDimension: string
βββ defaultValue: any
βββ rules: Rule[]
File: Store.ts
The central data management class - a managed, observable collection of in-memory records.
import {Store} from '@xh/hoist/data';
const store = new Store({
fields: [
{name: 'name', type: 'string', displayName: 'Full Name'},
{name: 'salary', type: 'number', defaultValue: 0},
{name: 'department', type: 'string'},
{name: 'hireDate', type: 'localDate'}
],
data: initialData // Each record should have an 'id' property (default idSpec)
});| Property | Type | Default | Description |
|---|---|---|---|
fields |
Array<string | FieldSpec | Field> |
- | Schema definition |
fieldDefaults |
Omit<FieldSpec, 'name'> |
- | Defaults applied to all fields |
idSpec |
string | Function |
'id' |
Property name or function to derive record IDs |
data |
PlainObject[] |
- | Initial data to load |
processRawData |
(raw) => PlainObject |
- | Transform raw data before parsing |
filter |
FilterLike |
- | Initial filter |
filterIncludesChildren |
boolean |
false |
Include children when parent passes filter |
loadTreeData |
boolean |
true |
Enable hierarchical loading |
loadTreeDataFrom |
string |
'children' |
Property containing child records |
loadRootAsSummary |
boolean |
false |
Treat root node as summary record |
freezeData |
boolean |
true |
Freeze record data objects for immutability (set false as a performance optimization) |
digestSpec |
string | fn |
null |
Derive a digest from each raw object, reusing records whose digest is unchanged (performance) |
retainRaw |
boolean |
true |
Retain raw data reference on each record (set false to reduce memory) |
projectionOnly |
boolean |
null |
Read-only projection of data parsed elsewhere - adopts raw objects as record data. Recommended for View-connected stores |
idEncodesTreePath |
boolean |
false |
IDs imply a fixed tree position (performance). Not supported on View-connected stores |
validationIsComplex |
boolean |
false |
Validate all uncommitted records on every change |
experimental |
PlainObject |
{} |
Flags for experimental features - see Performance and Memory |
xhName |
string |
null |
Developer-facing name for logs, telemetry, and Inspector |
Store.defaults exposes freezeData for an app-wide override. See StoreDefaults for details.
loadData(rawData, rawSummaryData?) - Complete dataset replacement:
// Flat data
store.loadData([
{id: 1, name: 'Alice', salary: 100000},
{id: 2, name: 'Bob', salary: 90000}
]);
// Hierarchical data (children nested automatically)
store.loadData([
{
id: 'eng',
name: 'Engineering',
children: [
{id: 'eng-1', name: 'Alice'},
{id: 'eng-2', name: 'Bob'}
]
}
]);updateData(rawData | transaction) - Transactional updates that preserve local modifications:
// Simple array form - adds or updates based on ID match
store.updateData([
{id: 1, salary: 110000} // Updates existing
]);
// Transaction form - explicit control
store.updateData({
update: [{id: 1, salary: 110000}],
add: [{id: 3, name: 'Carol', salary: 95000}],
remove: [2],
rawSummaryData: {id: 'summary', totalSalary: 5200000}
});updateData() returns a StoreChangeLog reporting the changes it actually applied, or null if it
made none. Note that remove holds the removed StoreRecords, not their ids - a removed record is
no longer resolvable against the Store. Read record.id where you need ids. modifyRecords()
returns a StoreChangeLog in the same form.
const changes = store.updateData(rawData);
if (changes) {
console.log(changes.add.length, changes.update.length, changes.remove.length);
}A transaction may include changedFields - a Set of the field names whose values changed across
its update rows:
store.updateData({update: tickingRows, changedFields: new Set(['lastPrice', 'volume'])});Providing it is an assertion: the updates change record values only (no parent/structural changes),
and no field outside the set changed. Hoist carries the set through to the Grid transaction sync,
which uses it to prove that an update cannot affect row order and skip ag-Grid's re-sort entirely -
a major win for high-frequency updates into large sorted grids. Cube Views supply changedFields
automatically on streaming updates, so view-connected stores get this for free - and consume it on
the way in, diffing only the declared fields on each updated leaf when a Cube.updateDataAsync()
transaction supplies the set. Producers that cannot cheaply determine the set should simply omit
it - the grid falls back to comparing sorted field values record-by-record where possible.
loadDataAsync(rawData) - Streaming counterpart to loadData(). It accepts a sync or async
iterable that yields raw records, and creates records incrementally without buffering the complete
raw dataset in memory. Use it for very large datasets streamed from the server. Pair it with
XH.fetchNdjson() for NDJSON:
await store.loadDataAsync(XH.fetchNdjson({url: 'myRows'}).lines);The Store updates in a single transaction once the source completes, and remains unchanged if the
source throws. Stores with loadRootAsSummary cannot stream, as such payloads nest all rows within
a single root node.
This method does not accept summary data, because an aggregate cannot precede its stream. Install a
summary afterwards via updateData({rawSummaryData}).
Order is not a guaranteed property of a Store. Loads are free to preserve the positions of incumbent records, so a payload that differs from the current dataset only in its ordering processes as a no-op. Apply an explicit sort wherever deterministic order matters - a grid sort, or a sort on an ordinal field supplied with the source data.
Stores track uncommitted changes separately from server-sourced data:
// Add new records - data must include a literal id, as addRecords() skips idSpec
store.addRecords([{id: XH.genId(), name: 'New Employee'}]);
// Modify field values on existing records
store.modifyRecords([{id: 1, salary: 120000}]);
// Remove records
store.removeRecords([recordOrId]);
// Query modification state
store.isDirty; // Any uncommitted changes?
store.addedRecords; // Records added locally
store.removedRecords; // Records removed locally
store.dirtyRecords; // Records modified locally
store.modifiedRecords; // Alias for dirtyRecords
store.committedRecords; // Records as originally loaded
// Revert
store.revertRecords([1, 2]); // Discard changes to specific records
store.revert(); // Discard all local changesThere is no explicit commit call. Records become committed when the server or other source of record
sends them back through loadData() or updateData(). A typical flush posts
record.getModifiedValues() to the server, then loads the server response.
// Set filter
store.setFilter({field: 'department', op: '=', value: 'Engineering'});
// Compound filter
store.setFilter({
op: 'AND',
filters: [
{field: 'department', op: '=', value: 'Engineering'},
{field: 'salary', op: '>=', value: 50000}
]
});
// Clear filter
store.clearFilter();
// Re-run the current filter after external state it depends on has changed
store.refreshFilter();
// Access filtered vs unfiltered data
store.records; // Filtered records
store.allRecords; // All records (ignores filter)
store.count; // Filtered record count
store.allCount; // Total record countStore re-filters automatically whenever record data changes. Call refreshFilter() only when the
state behind a FunctionFilter changes without any change to the records themselves.
Stores are fully observable for MobX reactivity:
// React to data changes
this.addReaction({
track: () => store.records,
run: records => console.log('Records changed:', records.length)
});
// Key observables
store.records // Filtered records
store.count // Filtered count
store.allCount // Total count
store.empty // No records?
store.maxDepth // Deepest nesting level
store.lastUpdated // Timestamp of last change
store.lastLoaded // Timestamp of last loadData callFile: StoreRecord.ts
Wrapper around each data element providing state tracking, validation, and tree navigation.
const record = store.getById(1);
// Data access
record.id; // Unique identifier
record.data; // Current field values - read by field name only
record.committedData; // Last committed state
record.raw; // Original raw data (null if retainRaw: false, or a local add)
record.digest; // Digest snapshotted per digestSpec, if configured
// State predicates
record.isAdd; // Never committed (new record)
record.isDirty; // Has uncommitted changes
record.isModified; // Alias for isDirty
record.isCommitted; // No local modifications
// Field access
record.get('salary'); // Single field value
record.getValues(); // All field values (with defaults)
record.getModifiedValues(); // Only changed fields, plus id (null if none)
record.matchesData({salary: 120000}) // Test against a partial data objectRead record.data by field name only. Store optimizes its internal layout for memory and varies it
per record, so Object.keys(), object spread, and JSON.stringify() do not reliably see every
field. Call getValues() to enumerate all field values, or getModifiedValues() to read
locally-modified values only. See
Tuning Memory for Large Datasets for the reason behind this.
// β Wrong: enumeration can miss fields that hold their default value
const clone = {...record.data};
const json = JSON.stringify(record.data);
// β
Correct: getValues() carries an own property for every declared Field
const clone = record.getValues();
const json = JSON.stringify(record.getValues());For hierarchical data, records support navigation without direct object references:
record.parentId; // Parent record ID
record.parent; // Parent StoreRecord
record.children; // Direct children (filtered)
record.allChildren; // Direct children (unfiltered)
record.descendants; // All descendants (filtered)
record.allDescendants; // All descendants (unfiltered)
record.ancestors; // All ancestors (filtered)
record.allAncestors; // All ancestors (unfiltered)
record.depth; // Nesting level (0 for roots)
record.treePath; // Array of ancestor IDs, ending with this record's own
// Iteration variants, to avoid allocating intermediate arrays
record.forEachChild(fn);
record.forEachDescendant(fn);
record.forEachAncestor(fn);record.validationState; // 'Valid' | 'NotValid' | 'Unknown'
record.isValid; // Boolean shortcut
record.isNotValid; // Boolean shortcut
record.errors; // Field name β error messages
record.errorCount; // Total error count
record.allErrors; // Flat array of all error messages
record.validationResults; // Field name β ValidationResult[]
record.isValidationPending; // Async validation in progress?File: Field.ts
Metadata descriptor defining type parsing, defaults, display names, descriptions, and validation
rules. The displayName and description properties flow from Field to Column automatically,
providing defaults for grid headers, tooltips, and chooser descriptions. A GroupingChooserModel
bound to a GridModel or View offers every field with isDimension: true for selection.
const store = new Store({
fields: [
// Simple string form
'lastName',
// Full configuration
{
name: 'salary',
type: 'number',
displayName: 'Annual Salary',
description: 'Total annual compensation before taxes',
defaultValue: 0,
rules: [required, numberIs({min: 0})]
},
{name: 'department', type: 'string', isDimension: true},
{name: 'hireDate', type: 'localDate'},
// Opt in to DOMPurify escaping of incoming string values
{name: 'comment', type: 'string', enableXssProtection: true}
]
});Hoist disables XSS protection by default, in keeping with its primary use case of secured internal
apps with large datasets. Set enableXssProtection per field, or app-wide via
AppSpec.enableXssProtection, for apps that display content from untrusted sources.
| Type | Description | Parsing |
|---|---|---|
'auto' |
No parsing (default) | Pass-through |
'string' |
Text values | Converts to string |
'number' |
Floating point | Parses numeric strings |
'int' |
Integer | Parses and rounds |
'bool' |
Boolean | Handles 'true'/'false' strings |
'date' |
Date/time | Parses to Date object |
'localDate' |
Date only | Parses to LocalDate |
'json' |
JSON data | Parses JSON strings |
'tags' |
String array | Splits comma-separated |
'pwd' |
Password | Marks as sensitive |
Files: filter/Filter.ts, filter/FieldFilter.ts, filter/CompoundFilter.ts, filter/FunctionFilter.ts
Composable, immutable filter architecture with JSON serialization support.
Compares field values against candidate values:
import {FieldFilter} from '@xh/hoist/data';
// Equality
{field: 'status', op: '=', value: 'active'}
{field: 'status', op: '!=', value: 'deleted'}
// Comparison (return false for null values)
{field: 'salary', op: '>=', value: 50000}
{field: 'age', op: '<', value: 65}
// String matching (case-insensitive)
{field: 'name', op: 'like', value: 'smith'} // Contains
{field: 'name', op: 'begins', value: 'A'} // Starts with
{field: 'email', op: 'ends', value: '@acme.com'} // Ends with
// Negated string matching
{field: 'name', op: 'not like', value: 'test'}
{field: 'code', op: 'not begins', value: 'TMP'}
{field: 'file', op: 'not ends', value: '.bak'}
// Array operations (for array-valued fields)
{field: 'tags', op: 'includes', value: 'urgent'}
{field: 'roles', op: 'excludes', value: 'guest'}FieldFilter.OPERATORS lists every supported operator. Hoist logs a console warning when a filter
names a field the target Store does not declare.
Certain operators accept an array as their value, and match if the field equals any of the
supplied values. Prefer this form for multi-value matching on a single field, rather than a
compound OR filter:
// Match any of these statuses - preferred form
{field: 'status', op: '=', value: ['active', 'pending', 'review']}
// Equivalent but more verbose - avoid
{
op: 'OR',
filters: [
{field: 'status', op: '=', value: 'active'},
{field: 'status', op: '=', value: 'pending'},
{field: 'status', op: '=', value: 'review'}
]
}
// Also works with other operators
{field: 'department', op: '!=', value: ['HR', 'Legal']} // Exclude multiple
{field: 'name', op: 'like', value: ['smith', 'jones']} // Match any substringFieldFilter.ARRAY_OPERATORS lists the operators that support array values. The four range
operators (>, >=, <, <=) do not.
Combines multiple filters with AND/OR logic:
import {CompoundFilter} from '@xh/hoist/data';
// AND (all must pass)
{
op: 'AND',
filters: [
{field: 'department', op: '=', value: 'Engineering'},
{field: 'salary', op: '>=', value: 50000}
]
}
// Nested
{
op: 'AND',
filters: [
{field: 'active', op: '=', value: true},
{
op: 'OR',
filters: [
{field: 'role', op: '=', value: 'admin'},
{field: 'role', op: '=', value: 'manager'}
]
}
]
}Custom filtering via a developer-supplied test function:
import {FunctionFilter} from '@xh/hoist/data';
// Cannot be serialized - use for dynamic/complex logic
store.setFilter(new FunctionFilter({
testFn: record => record.data.salary > record.data.minSalary
}));import {parseFilter, appendFilter, flattenFilter} from '@xh/hoist/data';
// Parse various input formats into Filter instances
const fieldFilter = parseFilter({field: 'name', op: 'like', value: 'smith'});
const andFilter = parseFilter([filter1, filter2]); // Wraps in AND
// Collect the leaf filters within a (possibly nested) CompoundFilter
const leaves = flattenFilter(andFilter);Every Filter subclass offers instance methods that return a new Filter | null with matching
filters removed. These methods traverse CompoundFilters recursively. Each method accepts an optional
argument to target a specific field or key, and removes all matching filters when called without
one.
// Remove FieldFilters targeting a specific field
const remaining = filter.removeFieldFilters('status');
// Remove ALL FieldFilters (e.g. keep only FunctionFilters)
const remaining = filter.removeFieldFilters();
// Remove a FunctionFilter by key
const remaining = filter.removeFunctionFilters('default');
// Remove ALL FunctionFilters
const remaining = filter.removeFunctionFilters();appendFilter() combines a source filter with one or more additions via AND. If the source is
already an AND CompoundFilter, it flattens the additions into that filter's children rather than
nesting them.
// Replace FieldFilters on one field, keep everything else
const updated = appendFilter(filter?.removeFieldFilters('status'), newStatusFilter);
// Replace all FieldFilters, preserving FunctionFilters
const updated = appendFilter(filter?.removeFieldFilters(), newFieldFilters);
// Append multiple additions at once
const updated = appendFilter(filter, addition1, addition2);
// Handles null gracefully
appendFilter(null, newFilter) // β newFilter
appendFilter(existingFilter, null) // β existingFilter
appendFilter(null, null) // β nullFiles: validation/Rule.ts, validation/constraints.ts
Validation with sync and async constraints, at three severity levels.
Rules live on Fields and consist of constraints with optional conditions:
import {required, numberIs, lengthIs} from '@xh/hoist/data';
const store = new Store({
fields: [
{
name: 'email',
type: 'string',
rules: [required, validEmail]
},
{
name: 'salary',
type: 'number',
rules: [
required,
numberIs({min: 0, max: 10000000})
]
},
{
name: 'notes',
type: 'string',
rules: [
lengthIs({max: 1000})
]
},
{
name: 'bonus',
type: 'number',
rules: [
// Conditional rule - only validate when salary exists
{
when: (field, values) => values.salary > 0,
check: numberIs({min: 0})
}
]
}
]
});| Constraint | Description |
|---|---|
required |
Non-null, non-empty value |
validEmail |
Valid email format |
validEmails(opts?) |
Multiple semicolon-separated emails |
lengthIs({min?, max?}) |
String length bounds |
numberIs({min?, max?, gt?, lt?, notZero?}) |
Numeric constraints |
dateIs({min?, max?, fmt?}) |
Date range (supports 'now', 'today') |
stringExcludes(...vals) |
Disallow specific substrings |
isValidJson |
Valid JSON format |
constrainAll(constraint) |
Apply constraint to each array element |
Constraints other than required pass null and empty values. Pair a constraint with required
where a value must be present.
Constraints are functions that receive (fieldState, allValues) and return null (valid) or an
error message/result:
// Simple constraint - first arg provides field state
const positiveNumber = ({value}) =>
value < 0 ? 'Must be positive' : null;
// Cross-field validation - second arg provides all record values
const endDateAfterStart = ({value}, allValues) => {
const {startDate} = allValues;
if (startDate && value && value < startDate) {
return 'End date must be after start date';
}
return null;
};
// Conditional requirement based on another field
const requireIfActive = ({value}, allValues) => {
if (allValues.status === 'active' && !value) {
return 'Required when status is active';
}
return null;
};
// Async constraint (e.g. server-side validation)
const uniqueEmail = async ({value}) => {
const exists = await XH.fetchJson({url: 'api/checkEmail', params: {email: value}});
return exists ? 'Email already in use' : null;
};Constraints can return results with different severity levels:
| Severity | Effect on isValid |
Use Case |
|---|---|---|
'error' |
Marks record invalid | Blocking issues the user must fix |
'warning' |
Record remains valid | Non-blocking concerns worth noting |
'info' |
Record remains valid | Informational hints or suggestions |
Only 'error' severity marks a record as invalid. The 'warning' and 'info' severities let
constraints give feedback without blocking form submission or other actions. Associated UI
components (e.g. form fields) can display these lesser severities to relay helpful information to
the end user.
// Return a string for error severity (default)
const required = ({value}) => isEmpty(value) ? 'Required' : null;
// Return a ValidationResult for other severities
const warnIfLarge = ({value}) =>
value > 1000000 ? {severity: 'warning', message: 'Unusually large value'} : null;
const suggestFormat = ({value}) =>
!value?.includes('-') ? {severity: 'info', message: 'Consider using dashes for readability'} : null;// Store-level validation
store.validator.validationState; // 'Valid' | 'NotValid' | 'Unknown'
store.validator.isValid; // Boolean
store.validator.errors; // Record id β (field name β string[])
store.validator.errorCount; // Total errors
store.validator.isPending; // Async validation in progress?
await store.validateAsync(); // Recompute all, resolve to true if valid
// Record-level validation
record.isValid;
record.errors; // Field name β string[]
record.validationResults; // Field name β ValidationResult[]By default, Store validates only the records affected by a change. Set validationIsComplex: true
to validate every uncommitted record on every add, modify, or remove. Use this where a rule on one
record depends on the values of another.
Stores are the primary data source for GridModel:
import {GridModel} from '@xh/hoist/cmp/grid';
import {numberEditor} from '@xh/hoist/desktop/cmp/grid';
const gridModel = new GridModel({
// Inline store config
store: {
fields: [
{name: 'name', type: 'string'},
{name: 'salary', type: 'number', rules: [required]}
]
},
columns: [
{field: 'name', flex: 1},
{field: 'salary', width: 120, editable: true, editor: numberEditor()}
]
});
// Load data through GridModel (delegates to store)
gridModel.loadData(data);
// Access store directly
gridModel.store.records;
gridModel.store.setFilter({field: 'salary', op: '>', value: 50000});GridModel also owns the StoreSelectionModel for its store, available as gridModel.selModel and
configured via the selModel config on GridConfig.
Stores fully support hierarchical parent-child data.
const store = new Store({
fields: ['name', 'type', 'headcount'],
loadTreeData: true, // Default
loadTreeDataFrom: 'children' // Default property name
});
store.loadData([
{
id: 'root',
name: 'Company',
type: 'org',
headcount: 150,
children: [
{
id: 'eng',
name: 'Engineering',
type: 'dept',
headcount: 50,
children: [
{id: 'eng-1', name: 'Alice', type: 'employee', headcount: 1},
{id: 'eng-2', name: 'Bob', type: 'employee', headcount: 1}
]
}
]
}
]);Note that updates cannot move a record between parents. To restructure a hierarchy, load the new
shape via loadData().
// Include children when parent matches
store.filterIncludesChildren = true;
// Or set during construction
new Store({
filterIncludesChildren: true
});Summary records hold aggregated totals or other derived data, displayed separately from regular records (e.g. in a grid's pinned footer row).
// Option 1: Load summary via second argument to loadData
store.loadData(
[/* regular records */],
{id: 'summary', totalSalary: 5000000, avgSalary: 75000} // Summary data
);
store.summaryRecords; // Array of summary StoreRecords
store.summaryRecord; // Convenience getter for the single-summary case
// Option 2: Use loadRootAsSummary for nested data structures
const store = new Store({
loadRootAsSummary: true
});
store.loadData([{
id: 'summary',
totalSalary: 5000000,
children: [/* actual records extracted as store.records */]
}]);Note: To display summary records in a Grid, set showSummary on the GridModel:
const gridModel = new GridModel({
store,
showSummary: true, // or 'top' | 'bottom' for specific placement
columns: [...]
});See cmp/grid/GridModel.ts for details on summary row rendering.
Client-side OLAP-style aggregation for multi-dimensional grouping and analysis. The Cube subsystem has its own dedicated documentation. See the Cube package README for full coverage of creating Cubes, aggregators, querying with Views, accessing results, and the recommended configuration for View-connected Stores.
Two points matter most to app code that reads View output:
- Leaf rows carry the id of their source cube record. Aggregate and bucket row ids encode the row's dimension path.
View.result.leafMapis null unless theQuerysetsincludeLeavesorprovideLeaves. Read the leaves behind a row with the exportedgetCubeLeaves()helper.
Store and Cube View optimize memory and update cost automatically, with no app configuration. The
sections below cover the further opt-in configs, and the behavior each one assumes of its data
source.
StoreRecords are immutable - Store freezes their data property by default. On loadData(), Store
compares new data fieldwise against the existing record with the same ID. If every field matches,
Store preserves the existing StoreRecord instance:
// First load
store.loadData([{id: 1, name: 'Alice', salary: 50000}]);
const record1 = store.getById(1);
// Second load - data unchanged, record instance reused
store.loadData([{id: 1, name: 'Alice', salary: 50000}]);
const record2 = store.getById(1);
record1 === record2; // true - same instance preserved
// Third load - data changed, new record created
store.loadData([{id: 1, name: 'Alice', salary: 55000}]);
const record3 = store.getById(1);
record1 === record3; // false - new instance with updated dataThis preserves ag-Grid row state (expansion, selection) for unchanged records across data refreshes.
A loadData() call that changes nothing at all preserves the Store's record collections outright,
and skips all downstream work.
For large datasets whose provider can cheaply identify unchanged records, set digestSpec to
derive a digest from each incoming raw object. Store snapshots that digest on the record it
builds, and reuses the record whenever a later raw object for the same id yields an equal digest.
Each hit skips raw data processing, parsing, and record creation.
const store = new Store({
digestSpec: 'lastUpdated' // digest is a raw property, e.g. a server-provided stamp
});
const store = new Store({
digestSpec: raw => raw.type + '|' + raw.seq // or derived - primitive values only
});Digests must be primitives (string or number), compared via ===. Build composite keys as
strings, and a null digest never matches. A provider that caches and re-supplies its own row objects
should stamp each row with a revision it bumps on every mutation and digest that - a stamp is the
only signal that distinguishes an unchanged row from one mutated in place.
This config applies to updateData() as well, where Store drops an unchanged-digest update as a
no-op and so preserves any uncommitted local modifications on that record. An update with a changed
digest builds a new record and overwrites local modifications, as updates always do. loadData()
misses still fall back to the standard fieldwise comparison.
Do not combine digestSpec with a processRawData function that depends on external state, as
Store bypasses that function for reused records.
Stores connected to a Cube View must leave this config unset. The View installs a digest that
reads a stamp it maintains on every row it publishes, and throws on connection if the app set an
explicit value. CubeConfig.store exposes the same config on the Cube's own internal store, where a
source that supplies per-row digests can preserve record identity across full reloads.
Set projectionOnly: true to mark a store as a read-only projection of data that its provider
parses and owns. Store then uses each incoming raw object as its record's data, by reference.
This collapses the usual two objects per row to one, and skips the per-row parse on every load and
update.
Use this config for stores connected to a Cube View, or fed by an endpoint that returns data in
its final client-side form. A View logs a warning when a connected store leaves the config unset.
Set it explicitly to false to opt out and silence that warning.
const store = new Store({
fields: [...],
projectionOnly: true
});
const view = cube.createView({
query: {dimensions: ['region', 'product']},
stores: store,
connect: true
});This mode carries real constraints:
- Raw data must already match what the Store's Fields would parse. Store applies neither
type,parseVal, nordefaultValue. - Store never modifies or freezes these objects, whatever the
freezeDatasetting, leaving the provider free to mutate rows in place. Rows re-supplied by reference are therefore always treated as changed - no value comparison can detect an in-place mutation. A provider that retains and mutates its own rows should supply adigestSpecto restore reuse. datacarries every key on the raw object, not only declared Fields. Only declared Field values take part in the equality checks that detect unchanged records.- The local modification APIs (
addRecords,modifyRecords,removeRecords,revertRecords, andrevert) throw. - Not compatible with
processRawData.
Store.loadDataAsync() accepts a sync or async iterable of raw records and creates records
incrementally, without buffering the complete raw dataset in memory. XH.fetchNdjson() is its
natural source. Cube.loadDataAsync() accepts a streaming source too. See
Data Loading above.
By default, every Store change - a transaction, reload, or refilter - rebuilds the affected
record collections in full, an O(total records) cost however small the change. For large stores
receiving small, frequent changes, the experimental maxPatchRatio config expresses changes as
small patch layers over a shared base collection instead, making transaction, filtering, and
grid-sync costs scale with the size of the change:
const store = new Store({
experimental: {maxPatchRatio: 0.1} // patches capped at 10% of total records
});A patch that grows past the configured fraction of total records is flattened away (clamped to
[0, 0.5] - the default of 0 disables patching). The ratio is read live on each operation, so it
may also be changed on an existing Store at any time, and can be applied app-wide via the
xhStoreExperimental soft-config. Two behavioral notes:
- Record order becomes stable-by-incumbency rather than source-order: existing records keep their positions and additions append, including adds within partial reloads and records entering a filter incrementally. Apply a grid sort where deterministic order matters.
- Incremental refiltering assumes filter outcomes depend only on each record's own data. Call
Store.refreshFilter()if a filter's results can change for external reasons.
For stores holding tens of thousands of records or more, two independent knobs reduce retained memory. They stack, and both are opt-in:
| Knob | What it does | When to use |
|---|---|---|
retainRaw: false |
Drops each record's reference to its raw source object once parsed | Your app never reads StoreRecord.raw |
internStrings (a FetchOptions config) |
Deduplicates repeated string values within a response, and optionally across refetches of the same dataset | Your data repeats many string values (categories, statuses, names) |
Store builds record data objects for memory efficiency out of the box, and picks a representation
per record. Sparsely-populated records carry own properties only for fields that hold non-default
values, and reach their defaults through a shared prototype. Store clones densely-populated records
from a shared per-Store template that carries every declared field. Both forms stay in V8's compact
"fast properties" mode.
Wide objects built instead by per-field property adds would fall back to a per-object hashtable past roughly 20 adds, at several times the memory per record.
One consequence deserves attention: Object.keys(), spread, and JSON.stringify() see own
properties only, which vary with each record's density. Call record.getValues() or
record.getModifiedValues() instead of enumerating data directly.
The experimental.denseRecordThreshold config governs the crossover between the two forms. It
exists for testing and tuning only. Set it above the field count of any record to force the sparse
form throughout.
Store, Cube View, and GridModel each expose a diagnostics object with one slot per kind of
operation. Each slot reports the work done, the elapsed time, and the path taken.
store.diagnostics.load; // Last load op, plus count and total elapsed ms
store.diagnostics.update;
store.diagnostics.filter;
const {type, add, update, remove, total, elapsed} = store.diagnostics.update.last;
// type: 'patched' | 'flattened' | 'full' | 'unchanged'
store.diagnostics.reset();Diagnostics log at debug level by default. Set diagnostics.logLevel = 'info' on one instance to
follow that object alone at any XH.logLevel. Give the owning objects an xhName to tell
their output apart - several stores logging at once are otherwise all labelled [Store].
gridModel.store.diagnostics.logLevel = 'info';
gridModel.diagnostics.logLevel = 'info';This API supports app troubleshooting and benchmarking only. It can change without notice at any release.
The Hoist Inspector provides a built-in UI for these diagnostics - select any Store, Cube View, or GridModel in its Instances grid to see a live readout.
Transform data before it enters the Store:
const store = new Store({
fields: ['fullName', 'salary'],
processRawData: raw => {
raw.fullName = `${raw.firstName} ${raw.lastName}`;
raw.salary = raw.salary / 100; // Convert cents to dollars
return raw;
}
});For efficiency, prefer modifying and returning the raw object in place, as above. The raw data is
typically transient, so there is no need to allocate a clone. If the app does cache, share, or
otherwise re-use the raw data, return a modified clone instead. In-place edits are also visible on
StoreRecord.raw.
For records without a single ID field, use a function to derive the ID:
const store = new Store({
fields: ['region', 'product', 'year', 'revenue'],
idSpec: data => `${data.region}-${data.product}-${data.year}`
});Store can also read a property other than id from the source data. The value must be unique across
all data elements, and Store still installs it as StoreRecord.id on the records it constructs.
const store = new Store({
fields: ['region', 'product', 'year', 'revenue'],
idSpec: 'productUUID'
});store.getById(1); // Record, or null
store.getById(1, true); // Restrict to post-filter records
store.getChildrenById('eng'); // Children of a record
store.getDescendantsById('eng');
store.getAncestorsById('eng-1');
store.recordIsFiltered(record); // In the store, but excluded by the filter?
store.getField('salary'); // Field instance by name
store.fieldNames; // Names of all declared fields
store.getValuesForFieldFilter('status', filter); // Candidate values for a filter UIThe id property is a top-level property of StoreRecord, not a field. Do not include it in the
fields configuration:
// β Wrong: id is not a field
const store = new Store({
fields: [
{name: 'id'},
{name: 'name'},
{name: 'salary'}
]
});
// β
Correct: Only define actual data fields
const store = new Store({
fields: [
{name: 'name'},
{name: 'salary'}
]
});
// Store derives record IDs from the 'id' property in raw data by default (idSpec: 'id')Records added via addRecords() must include a literal id in the raw data. This method does not
run the Store's idSpec function:
// β Wrong: Missing ID
store.addRecords([{name: 'New Employee'}]);
// β
Correct: Generate ID
store.addRecords([{id: XH.genId(), name: 'New Employee'}]);Store optimizes the internal data layout for memory and varies it per record, so enumeration does
not reliably see every field:
// β Wrong: can silently miss fields holding their default value
const values = {...record.data};
// β
Correct: an own property for every declared Field
const values = record.getValues();Modify record data through Store APIs, not by direct mutation:
// β Wrong: Direct mutation bypasses tracking
record.data.salary = 100000;
// β
Correct: Use Store API
store.modifyRecords([{id: record.id, salary: 100000}]);A Store makes no ordering guarantee. Do not read store.records positionally, and do not expect a
reload to reorder rows:
// β Wrong: assumes the Store preserves source order
const newest = store.records[0];
// β
Correct: sort explicitly, e.g. on an ordinal field from the source data
const newest = maxBy(store.records, r => r.data.seq);StoreChangeLog.remove holds the removed StoreRecords, not their ids. Those records are no longer
resolvable against the Store:
const {remove} = store.updateData(transaction);
// β Wrong: remove holds records, and getById() cannot resolve them anyway
remove.forEach(id => console.log(store.getById(id).data.name));
// β
Correct: read the records directly
remove.forEach(rec => console.log(rec.data.name));FunctionFilters work fine for runtime filtering, but Hoist cannot serialize them. This becomes a
problem when the app must persist the filter (e.g. via @persist):
class MyModel extends HoistModel {
// β Problem: Hoist cannot serialize a FunctionFilter for persistence
@observableRef
@persist
accessor filter: Filter = new FunctionFilter({testFn: r => r.data.custom > 0});
// β
Correct: FieldFilter/CompoundFilter are serializable
@observableRef
@persist
accessor filter: Filter = parseFilter({field: 'custom', op: '>', value: 0});
}/core/- HoistModel, HoistBase - base classes Store extends/data/cube/- Cube, View, Query - multi-dimensional aggregation/cmp/grid/- GridModel consumes Store for data display/cmp/form/- FormModel uses similar Field and validation patterns/svc/- FetchService, includingfetchNdjson()andinternStrings/cmp/filter/- UI components for filter construction/cmp/grouping/- GroupingChooser for specifying multi-level dimension groupings