Skip to content

Latest commit

Β 

History

History
922 lines (676 loc) Β· 29.5 KB

File metadata and controls

922 lines (676 loc) Β· 29.5 KB

Coding Conventions

Overview

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.

Principles

These higher-level principles guide coding decisions across the codebase. The specific conventions in later sections are expressions of these values.

Don't Repeat Yourself

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.

Clear, Descriptive Naming

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);

Prefer Lodash for Utility Operations

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 values

Keep Code Concise

Favor 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.

Tooling-Enforced Style

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 avoided
  • eslint.config.js β€” Linting: @xh/eslint-config base rules + TSDoc syntax checking via eslint-plugin-tsdoc + Prettier integration
  • tsconfig.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.

Imports

Import Ordering

When writing new code, prefer organizing imports in three groups separated by blank lines:

  1. External libraries β€” third-party packages (react, lodash, classnames)
  2. @xh/hoist packages β€” framework imports using the @xh/hoist/ path alias
  3. 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';

Named Imports

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';

Type Imports

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';

Barrel Exports

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';

TypeScript Usage

interface vs type

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 Usage

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.

Generics

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> { ... }

override Keyword

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) { ... }
}

declare config

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
}

readonly

Use readonly for properties set once in the constructor and never reassigned:

readonly collapsible: boolean;
readonly resizable: boolean;
readonly side: BoxSide;

No I Prefix

Do not prefix interface names with I. Use descriptive names like Config, Props, Spec, Options:

// βœ… Do
interface PanelConfig { ... }
interface GridProps { ... }

// ❌ Don't
interface IPanelConfig { ... }

Naming Conventions

Files

  • 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)

Interfaces and Types

  • Config suffix for model/class constructor option types (PanelConfig, GridConfig)
  • Props suffix for React component prop types (PanelProps, GridProps)
  • Spec suffix for declarative configuration objects (DashViewSpec, FieldSpec, LoadSpec)
  • Options suffix for optional parameter bundles (GridAutosizeOptions, SearchOptions)

Methods

  • Async suffix for methods returning Promises (doLoadAsync, refreshAsync, deleteRecordAsync, completeAuthAsync)

Private Members

Use the private keyword for internal properties not intended for external use:

private committed: RecordSet;
private dataDefaults = null;
private fieldMap: Map<string, Field>;

Constants

  • UPPER_SNAKE_CASE for true constants and enum-like values (MINUTES, MILLISECONDS)

Framework Prefixes

  • xh prefix for CSS classes, custom properties, and framework-level identifiers (xh-panel, xh-grid, --xh-panel-bg)
  • XH for the framework singleton entry point

Class Structure

Member Ordering

Hoist classes follow a canonical ordering for readability and consistency. Not every class has all sections, but when present they appear in this order:

  1. Static members β€” static properties and methods
  2. declare config β€” TypeScript config type declaration
  3. Immutable properties β€” readonly properties set in constructor
  4. @managed properties β€” child objects with managed lifecycle
  5. Observable state β€” @observable and @bindable properties
  6. Computed getters β€” @computed derived state
  7. Constructor β€” initialization logic
  8. Lifecycle hooks β€” onLinked, afterLinked, doLoadAsync, destroy
  9. Public methods β€” @action methods and public API
  10. Private implementation β€” private properties and internal helpers

Section Dividers

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.

Constructor Pattern

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;
}

Model vs Service

  • 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 via XH.installServicesAsync(), accessed as XH.myService. Hold app-wide state and provide data access methods.

Component Patterns

hoistCmp.withFactory vs hoistCmp.factory

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
    }
});

Model Binding

The model option in hoistCmp.withFactory declares how a component finds its model:

  • uses(ModelClass) β€” looks up or creates a model of the given class
  • uses(ModelClass, {fromContext: false}) β€” always creates a new model (no context lookup)
  • false β€” component has no model association

Local Models

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;
    // ...
}

displayName

Always set displayName on components. It appears in React DevTools and error messages. It should match the PascalCase export name.

className

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.

Element Factories vs JSX

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.

Export Patterns

Named Exports Only

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 { ... }

Component Export Pairs

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.

Variable Declarations

Multi-Variable const

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.

Null and Undefined

null Preferred

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;

== null Pattern

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;

Utility Functions

  • isNil(value) β€” lodash function, equivalent to value == null. Used when the check reads more naturally as a predicate (e.g., in conditions with other is* checks)
  • withDefault(...args) β€” returns the first argument that is not undefined. Useful for providing fallback values from config where null is a valid, intentional value that should be respected (not treated as "missing"):
this.sortable = withDefault(sortable, true);

Comments and Documentation

Copyright Header

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.

Section Dividers

See Class Structure > Section Dividers above. Comment dividers are also used at the module level to separate groups of related functions or constants.

TSDoc

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.

Avoid Em Dashes in Code Comments

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.

Observable Annotation Comments

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;

Step-Numbered Comments

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);

Async Patterns

async/await

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);
}

await vs return on the Last Line

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).

Promise Extensions

Hoist extends the Promise prototype with chainable methods. The most common:

  • .catchDefault() β€” catches and passes to XH.handleException() with default options
  • .track({model, category}) β€” links to a TaskObserver for 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.

Timer.create()

For recurring async operations, use Timer.create() with an interval:

Timer.create({
    runFn: () => this.refreshAsync(),
    interval: 30 * SECONDS
});

Error Handling

XH.handleException()

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.'});
}

catchDefault()

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.

throwIf()

Use for precondition assertions that throw on violation:

throwIf(!this.store, 'Store is required');
throwIf(this.readonly, 'Cannot edit in read-only mode');

catch (ignored)

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.

Logging

Instance Methods

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);

Standalone Functions

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');

Timed Wrappers

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'});
});

Logging Decorators

@logWithInfo and @logWithDebug wrap a method to log its execution time:

@logWithDebug
syncFromStore() {
    // ... method body is timed and logged at debug level
}

apiDeprecated()

Logs a deprecation warning (once per session) for APIs being phased out:

apiDeprecated('XH.appLoadModel', {
    msg: 'Use XH.appLoadObserver instead',
    v: '80'
});

Equality

=== Default

Use strict equality (=== / !==) for all comparisons except null checking. The codebase enforces this consistently.

== null Exception

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.

CSS Class Naming

xh- Prefix

All Hoist CSS classes are prefixed with xh-:

.xh-panel { ... }
.xh-grid { ... }
.xh-toolbar { ... }

BEM-Style Naming

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 { ... }
}

CSS Variables

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-bg

Key Source Files

These 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