This document catalogs the coding conventions used throughout hoist-react. It is written for both AI coding assistants generating hoist-react code and developers contributing to the library or building applications with it. The conventions here reflect established patterns in the codebase β they should be followed for consistency, but are not all mechanically enforced.
Where possible, conventions are enforced by tooling (ESLint, Prettier, TypeScript compiler). This document focuses on conventions that go beyond what tooling catches β patterns that are followed by convention rather than configuration.
These higher-level principles guide coding decisions across the codebase. The specific conventions in later sections are expressions of these values.
Extract shared logic rather than duplicating it. When the same pattern appears in multiple places, factor it into a utility, base class method, or shared helper. That said, balance DRY against readability β three similar lines of code can be clearer than a premature abstraction. Extract when there is a genuine, stable pattern β not when two blocks of code happen to look alike today.
Choose variable, method, and class names that are clear and descriptive without being verbose. Names should convey intent and read naturally:
// β
Clear and descriptive
const selectedRecord = store.getById(id);
const isEditable = !this.readonly && record.status === 'DRAFT';
// β Too terse
const r = store.getById(id);
const e = !this.readonly && record.status === 'DRAFT';
// β Overly verbose
const theCurrentlySelectedRecordFromTheStore = store.getById(id);Use lodash for collection and object utilities β it provides null-safe, battle-tested implementations that aid readability. Prefer native JS only when it is equally expressive:
// β
Native JS when equally clear
const names = users.map(u => u.name);
records.forEach(r => r.validate());
const active = items.filter(it => it.isActive);
// β
Lodash when it adds clarity or handles edge cases
import {isEmpty, groupBy, cloneDeep, castArray, compact} from 'lodash';
if (isEmpty(records)) return; // null-safe, works on objects too
const byStatus = groupBy(records, 'status'); // no native equivalent
const copy = cloneDeep(config); // deep clone
const items = castArray(itemOrItems); // normalize to array
const valid = compact(results); // remove falsy valuesFavor direct, compact expression over verbose or ceremonial patterns. Hoist's own utilities
(withDefault, throwIf, catchDefault, element factories) exist to reduce boilerplate β
use them. When standard JS provides a clean solution, prefer that over a more elaborate
construction.
The following config files define mechanically enforced style rules. Do not duplicate these rules in code reviews or conventions discussions β the tooling handles them:
.prettierrc.jsonβ Formatting: single quotes, 4-space indent (2 for SCSS/JSON), 100-char print width, trailing commas off, arrow parens avoidedeslint.config.jsβ Linting:@xh/eslint-configbase rules + TSDoc syntax checking viaeslint-plugin-tsdoc+ Prettier integrationtsconfig.jsonβ TypeScript:experimentalDecorators,noImplicitOverride,useDefineForClassFields,moduleResolution: "bundler", ES2022 target.stylelintrc.jsonβ SCSS linting (if present)
Run yarn lint to check all rules. Run yarn lint:code for JS/TS only or yarn lint:styles
for SCSS only.
When writing new code, prefer organizing imports in three groups separated by blank lines:
- External libraries β third-party packages (
react,lodash,classnames) @xh/hoistpackages β framework imports using the@xh/hoist/path alias- Relative imports β local files using
./or../paths
This is a soft recommendation, not a strict rule β existing files are not always consistent. No need to reformat existing imports, but new code benefits from the clarity:
// External libraries
import classNames from 'classnames';
import {castArray, isEmpty, isNil} from 'lodash';
import {ReactNode} from 'react';
// @xh/hoist packages
import {frame, vbox} from '@xh/hoist/cmp/layout';
import {HoistModel, hoistCmp, uses, XH} from '@xh/hoist/core';
import {Store} from '@xh/hoist/data';
import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx';
// Relative imports
import {MyHelper} from './impl/MyHelper';
import './MyComponent.scss';
import {MyModel} from './MyModel';Always use named imports. Lodash is imported from 'lodash' (not per-function subpaths like
'lodash/isEmpty'). MobX decorators and utilities are imported from '@xh/hoist/mobx'
(not directly from 'mobx') β Hoist's MobX module re-exports the public API with additional
enhancements.
// β
Do: Import from top-level lodash
import {isEmpty, isNil, castArray} from 'lodash';
// β Don't: Import from lodash subpaths
import isEmpty from 'lodash/isEmpty';
// β
Do: Import MobX through Hoist's re-export
import {observable, action, computed} from '@xh/hoist/mobx';
// β Don't: Import MobX directly
import {observable} from 'mobx';Use import type for type-only imports that are erased at compile time. This can be a standalone
import type statement or inline type qualifiers within a regular import:
// Standalone type import
import type {ColDef, GridOptions} from '@xh/hoist/kit/ag-grid';
// Inline type qualifier within a value import
import {HoistModel, type PlainObject, XH} from '@xh/hoist/core';Packages expose their public API through index.ts barrel files using export *:
// cmp/grid/index.ts
export * from './Grid';
export * from './GridModel';
export * from './GridSorter';
export * from './columns';
export * from './filter/GridFilterModel';Consumers import from the package path, not from individual files:
// β
Do: Import from the package barrel
import {GridModel, Column} from '@xh/hoist/cmp/grid';
// β Don't: Import from internal file paths
import {GridModel} from '@xh/hoist/cmp/grid/GridModel';Prefer interface for object shapes (configs, props, specs). Use type for unions, intersections,
mapped types, and other type-level operations:
// Interface for object shapes
interface GridConfig {
columns: Column[];
store: Store;
sortBy?: string;
}
// Type for unions and utility types
type SortDirection = 'asc' | 'desc';
type Awaitable<T> = T | Promise<T>;any is used in Hoist where strict typing would be impractical β particularly for generic
framework APIs, decorator implementations, and interop with loosely-typed third-party libraries.
This is intentional and pragmatic. Do not add any where a more specific type is readily available,
but do not over-engineer types for internal plumbing where any keeps code readable.
Use single uppercase letters for generic type parameters (T, S, M). Use descriptive names
when a class has multiple related type parameters:
class Store<T extends StoreRecord = StoreRecord> { ... }TypeScript's override keyword is required when overriding base class methods β
noImplicitOverride is enabled in tsconfig.json:
class MyModel extends HoistModel {
override onLinked() { ... }
override async doLoadAsync(loadSpec: LoadSpec) { ... }
}Models use TypeScript's declare keyword to narrow the inherited config property to their
specific config type. This is always the first member in the class body:
export class PanelModel extends HoistModel {
declare config: PanelConfig;
// ... rest of class
}Use readonly for properties set once in the constructor and never reassigned:
readonly collapsible: boolean;
readonly resizable: boolean;
readonly side: BoxSide;Do not prefix interface names with I. Use descriptive names like Config, Props, Spec,
Options:
// β
Do
interface PanelConfig { ... }
interface GridProps { ... }
// β Don't
interface IPanelConfig { ... }- PascalCase for files containing a primary class or component export (
GridModel.ts,Panel.ts,FetchService.ts) - camelCase for utility files and internal helpers (
index.ts,impl/ResizeContainer.ts)
Configsuffix for model/class constructor option types (PanelConfig,GridConfig)Propssuffix for React component prop types (PanelProps,GridProps)Specsuffix for declarative configuration objects (DashViewSpec,FieldSpec,LoadSpec)Optionssuffix for optional parameter bundles (GridAutosizeOptions,SearchOptions)
Asyncsuffix for methods returning Promises (doLoadAsync,refreshAsync,deleteRecordAsync,completeAuthAsync)
Use the private keyword for internal properties not intended for external use:
private committed: RecordSet;
private dataDefaults = null;
private fieldMap: Map<string, Field>;UPPER_SNAKE_CASEfor true constants and enum-like values (MINUTES,MILLISECONDS)
xhprefix for CSS classes, custom properties, and framework-level identifiers (xh-panel,xh-grid,--xh-panel-bg)XHfor the framework singleton entry point
Hoist classes follow a canonical ordering for readability and consistency. Not every class has all sections, but when present they appear in this order:
- Static members β static properties and methods
declare configβ TypeScript config type declaration- Immutable properties β
readonlyproperties set in constructor @managedproperties β child objects with managed lifecycle- Observable state β
@observableand@bindableproperties - Computed getters β
@computedderived state - Constructor β initialization logic
- Lifecycle hooks β
onLinked,afterLinked,doLoadAsync,destroy - Public methods β
@actionmethods and public API - Private implementation β
privateproperties and internal helpers
Use comment dividers to separate logical sections within a class. The format is a line of dashes inside a comment block:
export class MyModel extends HoistModel {
declare config: MyModelConfig;
//-----------------------
// Immutable Properties
//-----------------------
readonly name: string;
readonly sortable: boolean;
//---------------------
// Observable State
//---------------------
@observable selectedId: string = null;
@bindable filter: string = '';
constructor(config: MyModelConfig) {
super();
makeObservable(this);
// ...
}
//-----------------
// Actions
//-----------------
@action
setSelectedId(id: string) { ... }
//-----------------
// Implementation
//-----------------
private refreshData() { ... }
}The exact number of dashes is not significant β match the approximate width of the section label for visual balance.
Model constructors call super(), then makeObservable(this), then initialize properties from
config:
constructor(config: MyModelConfig) {
super();
makeObservable(this);
const {name, sortable = true, defaultFilter = ''} = config;
this.name = name;
this.sortable = sortable;
this.filter = defaultFilter;
}- Models (
HoistModel) β stateful, often multiple instances, lifecycle tied to component tree. Hold observable state, support loading/refresh, persist UI state. - Services (
HoistService) β singletons, installed at app startup viaXH.installServicesAsync(), accessed asXH.myService. Hold app-wide state and provide data access methods.
Library components use hoistCmp.withFactory, which returns a [Component, factory] pair.
The PascalCase name is the React component (exported for JSX usage); the camelCase name is the
element factory for functional-style rendering:
// Library/public component β exports both Component and factory
export const [Grid, grid] = hoistCmp.withFactory<GridProps>({
displayName: 'Grid',
model: uses(GridModel),
className: 'xh-grid',
render({model, className, ...props}, ref) {
// ... component render implementation
}
});Application components and internal implementation components typically use hoistCmp.factory,
which returns only the element factory. Since these components are rendered via factory calls (not
JSX), the PascalCase React component is not needed:
// App component β only the factory is used.
const tradeDetail = hoistCmp.factory<TradeDetailProps>({
displayName: 'TradeDetail',
model: uses(TradeDetailModel),
className: 'myapp-trade-detail',
render({model, className}) {
// ... component render implementation
}
});The model option in hoistCmp.withFactory declares how a component finds its model:
uses(ModelClass)β looks up or creates a model of the given classuses(ModelClass, {fromContext: false})β always creates a new model (no context lookup)falseβ component has no model association
The useLocalModel hook creates a model tied to the lifecycle of a component that will not be
discoverable to child components. This is useful when a component needs internal state specific to
a single instance or otherwise irrelevant to the primary model.
Prefer the name impl for local models.
render({model, className, ...rest}, ref) {
const impl = useLocalModel(GroupingChooserLocalModel),
{value, allowEmpty} = model,
{editorIsOpen} = impl;
// ...
}Always set displayName on components. It appears in React DevTools and error messages. It should
match the PascalCase export name.
Define a base CSS class in the component spec rather than hardcoding it inside the render function.
The framework automatically merges the spec's base class with any className passed by callers, so
every component consistently supports CSS class overrides without manual merging in render. The
merged value is provided to render() via props β apply it to the component's root element.
The xh- prefix is reserved for Hoist library components; applications should standardize on their
own app-specific prefix.
Hoist strongly prefers element factory calls over JSX. Factories are functions that take a config
object with an item/items key for children:
// β
Preferred: Element factory style
panel({
title: 'Users',
items: [grid({model: gridModel})],
bbar: toolbar(button({text: 'Save'}))
})
// Also supported: JSX style (rarely used by XH)
<Panel title="Users" bbar={<Toolbar><Button text="Save" /></Toolbar>}>
<Grid model={gridModel} />
</Panel>Factories also accept children as direct arguments when no other props are needed:
hbox(leftPanel(), rightPanel())items in, children out: item/items is Hoist's calling API. When authoring a
component, the render function receives those values as the standard React children prop, not as
items. The canonical container pattern is to destructure children from props and pass them on
to an inner factory as items. See
Authoring a Container Component
in the core README for the full explanation.
All exports are named. Default exports are not used:
// β
Do: Named exports
export class GridModel extends HoistModel { ... }
export const [Grid, grid] = hoistCmp.withFactory({ ... });
// β Don't: Default exports
export default class GridModel { ... }Library components export both the PascalCase component and camelCase factory via
hoistCmp.withFactory. The PascalCase form ensures the component is available for JSX usage:
export const [Panel, panel] = hoistCmp.withFactory({ ... });
export const [Grid, grid] = hoistCmp.withFactory({ ... });Application and internal implementation components typically export only the factory via
hoistCmp.factory, since they are rendered via factory calls rather than JSX.
When declaring multiple related variables, use comma-separated const declarations. This is
particularly common when destructuring alongside additional computed variables:
const {store, treeMode, filterModel} = model,
impl = useLocalModel(GridLocalModel),
maxDepth = impl.isHierarchical ? store.maxDepth : null,
container = enableFullWidthScroll ? vframe : frame;This pattern keeps related declarations together as a single logical group.
Hoist uses null (not undefined) as the conventional "no value" sentinel for observable
properties and return values. Properties are initialized to null rather than left undefined:
@observable selectedId: string = null;
@observable.ref lastResponse: Response = null;Use loose equality (== null / != null) when checking for both null and undefined.
This is an intentional exception to the general === preference β the codebase uses == null
extensively (~700 occurrences) for concise null-or-undefined checking:
// β
Do: Loose equality for null/undefined checks
if (value == null) return defaultValue;
if (record != null) process(record);
// β Don't: Verbose alternative
if (value === null || value === undefined) return defaultValue;isNil(value)β lodash function, equivalent tovalue == null. Used when the check reads more naturally as a predicate (e.g., in conditions with otheris*checks)withDefault(...args)β returns the first argument that is notundefined. Useful for providing fallback values from config wherenullis a valid, intentional value that should be respected (not treated as "missing"):
this.sortable = withDefault(sortable, true);Every .ts and .js source file begins with the copyright header:
/*
* This file belongs to Hoist, an application development toolkit
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
*
* Copyright Β© 2026 Extremely Heavy Industries Inc.
*/The year reflects the current copyright year. This header is required for all files in the hoist-react package. Application code does not use this header.
See Class Structure > Section Dividers above. Comment dividers are also used at the module level to separate groups of related functions or constants.
Public APIs use TSDoc comments (/** ... */). TSDoc syntax is checked by ESLint
(eslint-plugin-tsdoc). Use @param, @returns, @see, and @throws tags as appropriate:
/**
* Load data into the store, replacing any existing records.
*
* @param rawData - array of plain objects to load as records.
* @param rawSummaryData - optional summary row data.
*/
loadData(rawData: PlainObject[], rawSummaryData?: PlainObject) { ... }Match the existing comment density and style in the file. Comments should describe intent for a future reader who has no knowledge of any particular edit, or the history of the code in question. Rarely should comments reference what changed, what was removed, or what's new.
Generally, class- and method-level (TSDoc) comments should focus on the public API surface - what a caller needs to know to use the component, model, service, or method correctly - rather than narrating implementation details. Implementation notes belong in inline code comments next to the code they describe.
This is especially important for Hoist library code itself, which is consumed by downstream applications: the TSDoc on a public type or method is effectively its contract, and should be written from the caller's perspective.
Use - (spaced hyphen) rather than em dashes (β) for parenthetical asides in .ts
code comments and JSDoc. Em dashes can cause encoding issues with tooling (e.g. grep, MCP
tools) and offer no benefit in a monospace code context. Em dashes are fine in prose-style
.md documentation where they render naturally.
Other Unicode characters (arrows like β, accented letters, math symbols, etc.) are fine in
code comments when they aid clarity. The em-dash rule is specifically about the ambiguity
and tooling friction that character introduces, not a blanket ban on Unicode.
When a property's observable behavior is significant to callers, annotate it in a comment:
/** Currently selected record, or null if none. (observable) */
@observable.ref selectedRecord: StoreRecord = null;For multi-step processes, use numbered comments to guide readers through the sequence:
// 1) Parse the raw response into records
const records = this.parseResponse(raw);
// 2) Apply client-side filters
const filtered = this.applyFilters(records);
// 3) Update the store
this.store.loadData(filtered);Always use async/await over raw Promise chains. Methods that return Promises are suffixed
with Async:
async doLoadAsync(loadSpec: LoadSpec) {
const data = await XH.fetchService
.fetchJson({url: 'api/users'})
.catchDefault();
this.store.loadData(data);
}Use await without return when the method performs work but has no meaningful return value.
Most Hoist async methods (doLoadAsync, refreshAsync, deleteRecordAsync) fall into this
category β they produce side effects, not return values:
// β
Side-effect method β just await, no return
async doLoadAsync(loadSpec: LoadSpec) {
const data = await XH.fetchService.fetchJson({url: 'api/users'});
this.store.loadData(data);
}Use return (without await) only when the resolved value is a meaningful part of the method's
API contract:
// β
Method's job is to produce and return a value
async fetchUsersAsync(): Promise<User[]> {
return XH.fetchService.fetchJson({url: 'api/users'});
}Avoid return await β it is redundant in most cases. The one exception is inside a
try/catch, where return await is required for the local catch to handle rejections
(a plain return passes the promise through unwrapped, bypassing the catch).
Hoist extends the Promise prototype with chainable methods. The most common:
.catchDefault()β catches and passes toXH.handleException()with default options.track({model, category})β links to aTaskObserverfor loading masks/indicators.timeout(ms)β rejects if not settled within the given time.linkTo(observable)β writes resolved value to an observable property
See /promise/README.md for the full API.
For recurring async operations, use Timer.create() with an interval:
Timer.create({
runFn: () => this.refreshAsync(),
interval: 30 * SECONDS
});The primary error handling API. Parses, logs, and displays exceptions. In general, pass exceptions to the centralized handler rather than building custom error display logic. The exception is when an external API returns errors with a structured shape that should be parsed and reformatted into a useful message for the user β in that case, a wrapping layer that decodes the API-specific exception before passing it to the handler is appropriate:
try {
await this.saveAsync();
} catch (e) {
XH.handleException(e, {message: 'Failed to save record.'});
}Chain .catchDefault() on promises to handle errors via XH.handleException() with framework
defaults. Use this only for fire-and-forget cases such as onClick handlers, where there is
no subsequent code that depends on the result:
// β
Good: fire-and-forget handler β no code follows that depends on success
onSaveClick() {
this.saveAsync().catchDefault();
}Avoid .catchDefault() in model business logic β it swallows the exception and allows
execution to continue, which can leave variables undefined or state inconsistent. Use try/catch
with XH.handleException() instead when subsequent code depends on the operation succeeding.
Use for precondition assertions that throw on violation:
throwIf(!this.store, 'Store is required');
throwIf(this.readonly, 'Cannot edit in read-only mode');When a catch block intentionally discards the exception, name the parameter ignored:
try {
JSON.parse(raw);
} catch (ignored) {}For full coverage of error handling patterns and options, see Error Handling.
Classes extending HoistBase have logging methods that automatically include the class name
and instance ID in output:
this.logInfo('Loading data for', this.store.count, 'records');
this.logDebug('Filter applied:', filter);
this.logWarn('Unexpected state:', state);
this.logError('Failed to process:', e);For logging outside a class context, use the standalone functions from @xh/hoist/utils/js:
import {logInfo, logDebug, logWarn, logError} from '@xh/hoist/utils/js';
logInfo('Application started', 'App');withInfo() and withDebug() wrap a function call, logging its duration. They work with both
synchronous and async functions:
// Synchronous β logs duration of processing
const result = withDebug('Processing records', () => {
return this.processRecords(raw);
});
// Async β logs duration including await time
const data = await withDebug('Loading grid data', async () => {
return XH.fetchService.fetchJson({url: 'api/data'});
});@logWithInfo and @logWithDebug wrap a method to log its execution time:
@logWithDebug
syncFromStore() {
// ... method body is timed and logged at debug level
}Logs a deprecation warning (once per session) for APIs being phased out:
apiDeprecated('XH.appLoadModel', {
msg: 'Use XH.appLoadObserver instead',
v: '80'
});Use strict equality (=== / !==) for all comparisons except null checking. The codebase
enforces this consistently.
The one sanctioned exception: use == null / != null for null-or-undefined checks. This
pattern accounts for the vast majority of loose equality usage in the codebase. See
Null and Undefined above.
All Hoist CSS classes are prefixed with xh-:
.xh-panel { ... }
.xh-grid { ... }
.xh-toolbar { ... }Hoist uses BEM-inspired naming for elements and modifiers:
- Element separator
__:.xh-panel__inner,.xh-panel-header__title - Modifier separator
--:.xh-grid--hierarchical,.xh-grid--flat
.xh-panel {
&__inner {
flex: 1;
background-color: var(--xh-panel-bg);
}
}
.xh-grid {
&--hierarchical { ... }
&--flat { ... }
}Hoist's theme is built on CSS variables (custom properties) using the --xh- prefix. Applications
can reference these variables for consistent theming but should not define new variables with the
--xh- prefix β that namespace is reserved for the library:
--xh-panel-bg
--xh-panel-border-color
--xh-intent-primary
--xh-grid-bgThese files are good references for the conventions described above:
| File | Demonstrates |
|---|---|
core/HoistBase.ts |
Base class, logging, MobX integration, lifecycle |
core/model/HoistModel.ts |
Model pattern, declare config, lifecycle hooks |
core/HoistComponent.ts |
hoistCmp.withFactory, component definitions |
desktop/cmp/panel/PanelModel.ts |
Class structure, section dividers, member ordering, observables |
cmp/grid/GridModel.ts |
Large model with full structure, imports, actions |
cmp/grid/Grid.ts |
Component with factory pattern, multi-var const |
.prettierrc.json |
Formatting rules |
eslint.config.js |
Lint configuration |
tsconfig.json |
TypeScript compiler options |