| Section | Description |
|---|---|
| Overview | Purpose and principles of Hoist's built-in singleton services |
| Architecture | Service hierarchy, installation, and access via XH |
| Built-in Services | Reference for all 19 built-in services by category |
| Configuration Keys Reference | Soft config keys controlling service behavior |
| User Preference Keys Reference | Per-user preference keys used by services |
| Creating Custom Services | How to create and install application services |
| Common Patterns | FetchService in loads, tracking, debounced search, WebSockets |
| Common Pitfalls | Fetch errors, native fetch, missing loadSpec, and more |
| Related Packages | Links to core, cmp, promise, and admin packages |
The /svc/ package contains Hoist's built-in singleton services - classes that provide app-wide
functionality like HTTP requests, configuration, user preferences, and activity tracking. Services
are instantiated once during app initialization and accessed globally via the XH singleton.
Services extend HoistService (from /core/) and follow these principles:
- Singleton lifecycle - One instance per app, lives for app lifetime
- Global access - Available as
XH.serviceName(e.g.,XH.fetchService) - Async initialization - Can perform startup work in
initAsync() - MobX integration - Full support for observable state and reactions
HoistBase
βββ HoistService (singleton services)
βββ FetchService - HTTP requests
βββ ConfigService - Server configuration
βββ PrefService - User preferences
βββ IdentityService - Current user info
βββ EnvironmentService - App environment metadata
βββ TrackService - Activity tracking
βββ TraceService - Distributed tracing
βββ WebSocketService - Bidirectional messaging
βββ ... (19 built-in services total)
Services are installed during app startup via XH.installServicesAsync(). Hoist's core services
are installed automatically; applications add custom services in AppModel.initAsync():
class AppModel extends HoistAppModel {
override async initAsync(ctx: InitContext) {
// Install custom services - all initialize concurrently
await XH.installServicesAsync([TradeService, PortfolioService], ctx);
// Chain calls for ordered initialization (when services depend on earlier ones)
await XH.installServicesAsync([ReportService], ctx); // Can now use Trade/Portfolio services
}
}// Preferred: Use XH convenience methods where they exist
XH.fetchJson({url: 'api/data'});
XH.getConf('featureEnabled');
XH.getPref('gridPageSize');
XH.track('User exported data');
XH.getUser().roles;
XH.getEnv('appEnvironment');
// Full service access also available (for less common methods or properties)
XH.configService.list; // No alias - use service directly
XH.identityService.isImpersonating; // No alias - use service directly
XH.prefService.pushAsync('key', v); // No alias - use service directlyXH Convenience Methods:
| Method | Delegates To |
|---|---|
XH.fetch() |
fetchService.fetch() |
XH.fetchJson() |
fetchService.fetchJson() |
XH.postJson() |
fetchService.postJson() |
XH.getConf() |
configService.get() |
XH.getPref() |
prefService.get() |
XH.setPref() |
prefService.set() |
XH.track() |
trackService.track() |
XH.getEnv() |
environmentService.get() |
XH.getUser() |
identityService.user |
XH.getUsername() |
identityService.username |
File: FetchService.ts | Access: XH.fetchService or XH.fetch(), XH.fetchJson(), etc.
Managed HTTP requests with enhancements over the native Fetch API.
Key Features:
- Automatic correlation IDs for request tracking
- Configurable timeouts (default 30 seconds)
- Auto-abort of duplicate requests via
autoAbortKey - Request/response interceptors
- Rich exception handling with HTTP status, server details, and trace IDs
// Basic JSON request
const users = await XH.fetchJson({url: 'api/users'});
// POST with body
await XH.postJson({url: 'api/users', body: {name: 'John', role: 'admin'}});
// With timeout and abort key (cancels previous request with same key)
const results = await XH.fetchJson({
url: 'api/search',
params: {query: searchTerm},
timeout: 60000,
autoAbortKey: 'search'
});
// Pass loadSpec for consistent tracking in doLoadAsync()
override async doLoadAsync(loadSpec: LoadSpec) {
const data = await XH.fetchJson({url: 'api/data'}, {loadSpec});
}Configuration Options:
| Option | Type | Description |
|---|---|---|
url |
string | Request URL (relative URLs appended to XH.baseUrl) |
body |
any | Request body |
params |
object | Query string parameters |
headers |
object | Additional headers |
timeout |
number | Timeout in ms (default 30000) |
autoAbortKey |
string | Cancel previous requests with same key |
loadSpec |
LoadSpec | Metadata for tracking |
span |
Span | Parent span for tracing. Typically supplied via a Runner chain (runner.fetchJson(...)) rather than set directly |
App-Level Defaults (FetchService.defaults):
FetchService exposes a static defaults object for correlation ID configuration. Best configured
in the app's Bootstrap module to ensure settings are active from the very first request.
| Property | Type | Default | Description |
|---|---|---|---|
autoGenCorrelationIds |
boolean | function |
false |
Auto-generate correlation IDs. Set true or a (opts) => boolean function for per-request control |
genCorrelationId |
() => string |
16-char random string | Custom ID generator function |
correlationIdHeaderKey |
string |
'X-Correlation-ID' |
HTTP header name for correlation IDs |
// In Bootstrap module
FetchService.defaults.autoGenCorrelationIds = true;File: ConfigService.ts | Access: XH.configService or XH.getConf()
Provides access to soft-configuration values from the server. Configs are defined server-side and
must have clientVisible: true to be sent to the client.
// Get config value with optional default
const timeout = XH.getConf('sessionTimeout', 30);
const features = XH.getConf('enabledFeatures', []);
// Check available keys (no alias - access service directly)
XH.configService.list;Note: Configs are loaded once at startup and not auto-refreshed. App refresh required for updates.
File: PrefService.ts | Access: XH.prefService or XH.getPref(), XH.setPref()
Read and write user-specific preference values persisted on the server.
// Get preference with optional default
const pageSize = XH.getPref('gridPageSize', 50);
// Set preference (auto-saved with debounce)
XH.setPref('gridPageSize', 100);
// Immediate save - no alias, access service directly
await XH.prefService.pushAsync('criticalPref', value);
// Distinguish an explicit user value from the server-side default
if (XH.prefService.isSet('gridPageSize')) { /* user has customized this */ }
// Clear the user's value, reverting to the default (real server-side unset)
XH.prefService.unset('gridPageSize');Preferences are type-validated against server-defined types: string, int, long, double,
bool, json.
File: IdentityService.ts | Access: XH.identityService or XH.getUser()
Provides authenticated user information and impersonation support. See the Authentication concept doc for full coverage of OAuth flows, identity handling, and impersonation.
// Current acting user (apparent user when impersonating)
XH.getUsername();
XH.getUser().email;
XH.getUser().roles; // string[]
// Actual authenticated user (differs during impersonation) - no alias
XH.identityService.authUsername;
XH.identityService.isImpersonating;
// Impersonation (requires HOIST_IMPERSONATOR role) - no alias
await XH.identityService.impersonateAsync('targetUser');
await XH.identityService.endImpersonateAsync();File: EnvironmentService.ts | Access: XH.environmentService or XH.getEnv()
Provides app environment metadata and polls for server version changes.
// Environment info - use XH.getEnv() alias
XH.getEnv('appEnvironment'); // 'Production' | 'Development' | 'Staging' | etc.
// Helper methods and observable properties - access service directly
XH.environmentService.isProduction();
XH.environmentService.isTest();
XH.environmentService.serverVersion; // Polled - can change without app refresh
XH.environmentService.serverBuild;Version Change Handling: When server version changes, the service can prompt users to reload
or force an immediate reload based on xhEnvPollConfig.onVersionChange setting.
File: TrackService.ts | Access: XH.trackService or XH.track()
Activity tracking for audit trails and analytics, viewable in the Admin Console.
// Simple tracking
XH.track('User exported report');
// Detailed tracking
XH.track({
message: 'Portfolio loaded',
category: 'Data',
data: {portfolioId: 123, recordCount: 5000},
elapsed: loadTime
});TrackOptions:
| Option | Type | Description |
|---|---|---|
message |
string | What happened (required) |
category |
string | Activity category (e.g., 'Export', 'Grid') |
severity |
string | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' |
data |
any | Structured data about activity |
elapsed |
number | Operation duration in ms |
oncePerSession |
boolean | Only send first occurrence per session |
The client always sends all tracked entries to the server. The server then decides what to persist
based on the xhActivityTrackingConfig soft config (editable in the Admin Console). Key properties:
| Property | Default | Description |
|---|---|---|
levels |
[{username: '*', category: '*', severity: 'INFO'}] |
Rules controlling which entries are persisted |
maxDataLength |
2000 |
Max chars of JSON data per entry; oversized data is dropped with a warning |
logData |
false |
Whether to log primitive data values server-side |
clientHealthReport |
{intervalMins: -1} |
Config for periodic health report submissions; -1 disables |
maxRows |
{default: 10000, options: [...]} |
Row limits for the Admin Console activity viewer |
The levels array is the most important setting. Each rule specifies a username, category, and
severity β entries matching a rule at or above its severity are persisted. The default config drops
all DEBUG entries. To enable them:
{
"levels": [
{"username": "*", "category": "*", "severity": "DEBUG"}
],
"maxDataLength": 2000,
"logData": false,
"clientHealthReport": {"intervalMins": -1}
}Rules can also target specific users or categories:
{
"levels": [
{"username": "jsmith", "category": "*", "severity": "DEBUG"},
{"username": "*", "category": "Data", "severity": "DEBUG"}
]
}Rules are evaluated in order β the first match wins. Entries that don't match any rule default to an
INFO threshold, so the example above enables DEBUG+ persistence for user jsmith and the Data
category while all other entries continue to require INFO or above.
File: TraceService.ts | Access: XH.traceService
See Telemetry & Observability for the full guide to the
Runnerchain, tracing, metrics, and activity tracking.
Client-side distributed tracing β creates spans for user actions and fetch calls, injects
traceparent headers on outgoing requests, and batches completed spans for export to the
Hoist server. Exceptions thrown during traced operations include a traceId for correlation
with server-side traces. Controlled by the xhTraceConfig soft config. Requires hoist-core 37+.
Spans are sampled at creation time using xhTraceConfig.sampleRules β an ordered list of
tag-matching rules with glob pattern support. Child spans inherit their parent's sampling
decision. The traceparent header propagates the sampling flag to the server. See the
hoist-core tracing documentation for full sampling configuration details.
Applications instrument code via the Runner chain on HoistBase: runner(ctx?) starts
a chain, optionally seeded with a CallContextLike ({span?, loadSpec?}) β pass one through
from an upstream call to continue an existing trace / load, or call with no arg to start
fresh. Add a span with .span(name) and execute via the terminal methods β run(fn),
fetch(), fetchJson(), postJson(), etc. β which run the work inside the configured span,
with optional logInfo() / logDebug() / track() middleware composed in.
// Start a fresh chain with a span and run an async fn. `caller` is auto-set to `this`.
await this.runner().span('loadPortfolio').run(async ctx => {
const positions = await this.loadPositionsAsync(ctx);
this.setPositions(positions);
});
// Issue a fetch under a span, no manual span management.
const data = await this.runner().span('loadPortfolio').fetchJson({url: 'api/portfolio'});
// Continue an existing context (e.g. from a doLoadAsync loadSpec) and nest a child span.
async doLoadAsync(loadSpec) {
const data = await this.runner({loadSpec})
.span('refData')
.fetchJson({url: 'api/ref'});
}
// Forward a CallContext received from an upstream caller.
async fetchUserAsync(ctx: CallContext) {
return this.runner(ctx).span('user').fetchJson({url: 'api/user'});
}
// Compose with logging - times completion via withInfo/withDebug as appropriate.
await this.runner().span('loadPortfolio').logInfo('Loading portfolio').run(async ctx => { ... });
// Configure tags / kind via SpanConfig instead of a bare string.
await this.runner().span({name: 'loadPortfolio', tags: {portfolioId: id}}).run(async ctx => { ... });SpanConfig (passed to .span()):
| Option | Type | Description |
|---|---|---|
name |
string | Span name (required) |
kind |
SpanKind | 'internal' | 'client' | 'server' | 'producer' | 'consumer' |
tags |
PlainObject | Key-value attributes on the span |
The framework wires parent and caller automatically from the Runner chain β parent
comes from the chain's CallContext (with span derived from loadSpec.span when not set
explicitly), caller defaults to the HoistBase that started the chain (driving the
code.namespace tag).
The fetch* methods on FetchService (and the convenience aliases XH.fetch(),
XH.fetchJson(), XH.postJson()) also accept an optional CallContextLike as a second
argument β useful when threading context through a fetch without an enclosing Runner chain:
const data = await XH.fetchJson({url: 'api/data'}, {loadSpec});
``` To join a trace started upstream (e.g. a
`traceparent` propagated via WebSocket / SSE / queue messages), pass the string to a span
created directly via `XH.traceService.withSpan({name, parent: traceparent}, fn)` β the new
span adopts the remote `traceId`, `parentSpanId`, and `sampled` decision.
#### ClientHealthService
**File**: `ClientHealthService.ts` | **Access**: `XH.clientHealthService`
Gathers and reports client health metrics (memory, connection, WebSocket status).
```typescript
// Get current health snapshot
const report = XH.clientHealthService.getReport();
// {general: {...}, memory: {...}, connection: {...}, webSockets: {...}}
// Register custom data source for health reports
XH.clientHealthService.addSource('myFeature', () => ({
activeCount: myModel.activeItems.length,
cacheSize: myModel.cache.size
}));File: InspectorService.ts | Access: XH.inspectorService
Developer tool for monitoring HoistModel, HoistService, and Store instances at runtime. See
/inspector/ for full documentation on the Inspector UI.
// Toggle Inspector UI
XH.inspectorService.toggleActive();
// View active instances
XH.inspectorService.activeInstances; // InspectorInstanceData[]File: WebSocketService.ts | Access: XH.webSocketService
Maintains bidirectional WebSocket connection for server-push messaging.
// Subscribe to topic
const subscription = XH.webSocketService.subscribe('priceUpdates', (message) => {
console.log('Price update:', message.data);
});
// Clean up when done
subscription.destroy();
// Or save to @managed property for auto-cleanup on model destroy
// Send message to server
XH.webSocketService.sendMessage({topic: 'clientEvent', data: {action: 'click'}});
// Check connection status
XH.webSocketService.connected;
XH.webSocketService.channelKey; // Unique channel assigned by serverBuilt-in Topics:
xhHeartbeat- Connection health monitoringxhRegistrationSuccess- Server registration responsexhForceAppSuspend- Server-initiated app suspensionxhRequestClientHealthReport- Server requests health snapshot
File: AlertBannerService.ts | Access: XH.alertBannerService
Displays app-wide alert banners configured via the Admin Console. Automatically polls for updates
via EnvironmentService.
File: GridExportService.ts | Access: XH.gridExportService
Exports grid data to Excel or CSV format.
await XH.gridExportService.exportAsync(gridModel, {
filename: 'users-export',
type: 'excel', // 'excel' | 'excelTable' | 'csv'
columns: 'VISIBLE' // 'VISIBLE' | 'ALL' | string[]
});Features:
- Server-side export via multipart streaming
- Per-cell Excel formatting via column
excelFormatproperty - Respects tree hierarchy and grouped rows
- Custom export values via column
exportValueproperty
File: GridAutosizeService.ts | Access: XH.gridAutosizeService
Calculates optimal column widths based on content (more sophisticated than native ag-Grid).
await XH.gridAutosizeService.autosizeAsync(gridModel, ['name', 'status'], {
fillMode: 'right', // 'none' | 'all' | 'left' | 'right'
includeCollapsedChildren: false
});File: JsonBlobService.ts | Access: XH.jsonBlobService
Persists small unstructured JSON objects to the database.
// Create blob
const blob = await XH.jsonBlobService.createAsync({
type: 'savedFilter',
name: 'My Filter',
value: {field: 'status', op: '=', value: 'active'}
});
// Retrieve by token
const blob = await XH.jsonBlobService.getAsync(token);
// List by type
const filters = await XH.jsonBlobService.listAsync({type: 'savedFilter'});
// Update
await XH.jsonBlobService.updateAsync(token, {value: newFilterValue});
// Soft delete
await XH.jsonBlobService.archiveAsync(token);Use Case: Lightweight persistence for saved filters, UI layouts, sketches - data that doesn't warrant full domain objects.
Files: storage/LocalStorageService.ts, storage/SessionStorageService.ts
Simple key/value access to browser storage, auto-namespaced by app and user.
// Local storage (persists across sessions)
XH.localStorageService.set('lastTab', 'portfolio');
XH.localStorageService.get('lastTab', 'overview');
// Session storage (cleared when tab closes)
XH.sessionStorageService.set('tempState', {step: 2});File: IdleService.ts | Access: XH.idleService
Suspends the application after a period of inactivity to reduce server load.
Configuration (via xhIdleConfig soft config):
timeout- Global timeout in minutesappTimeouts- Per-app timeout overrides
File: AutoRefreshService.ts | Access: XH.autoRefreshService
Triggers automatic app-wide refresh at configurable intervals.
XH.autoRefreshService.enabled; // Current state (config + user preference)
XH.autoRefreshService.interval; // Interval in secondsConfiguration:
xhAutoRefreshIntervalssoft config - Interval per appxhAutoRefreshEnableduser preference - User opt-in/out
File: ChangelogService.ts | Access: XH.changelogService
Displays application changelog/release notes to users.
XH.changelogService.versions; // Parsed changelog entries
XH.changelogService.currentVersionIsUnread; // Has user seen current version?
XH.changelogService.markLatestAsRead();Services are configured via soft configs (managed in Admin Console):
| Key | Service | Purpose |
|---|---|---|
xhAutoRefreshIntervals |
AutoRefreshService | Refresh interval per app (seconds) |
xhChangelogConfig |
ChangelogService | Changelog display options |
xhIdleConfig |
IdleService | Idle timeout configuration |
xhInspectorConfig |
InspectorService | Inspector visibility/role restrictions |
xhExportConfig |
GridExportService | Export thresholds for UI feedback |
xhActivityTrackingConfig |
TrackService | Severity-level filtering, data limits, health report interval |
xhEnableImpersonation |
IdentityService | Enable/disable impersonation |
xhEnvPollConfig |
EnvironmentService | Poll interval and version change behavior |
| Key | Service | Purpose |
|---|---|---|
xhAutoRefreshEnabled |
AutoRefreshService | User's auto-refresh preference |
xhLastReadChangelog |
ChangelogService | Track latest read version |
xhIdleDetectionDisabled |
IdleService | Disable idle suspension for user |
See /core/README.md for the full guide on creating services. Quick example:
import {HoistService, XH} from '@xh/hoist/core';
import {observable} from '@xh/hoist/mobx';
export class PortfolioService extends HoistService {
@observableRef accessor portfolios: Portfolio[] = [];
// Called during app startup
override async initAsync() {
await this.loadPortfoliosAsync();
}
async loadPortfoliosAsync() {
const data = await XH.fetchJson({url: 'api/portfolios'});
runInAction(() => this.portfolios = data);
}
getPortfolio(id: string): Portfolio {
return this.portfolios.find(p => p.id === id);
}
}
// Install in AppModel.initAsync()
await XH.installServicesAsync([PortfolioService], ctx);
// Access anywhere
XH.portfolioService.getPortfolio('abc123');Always pass loadSpec to fetch calls for consistent tracking:
override async doLoadAsync(loadSpec: LoadSpec) {
const data = await XH.fetchJson({url: 'api/data'}, {loadSpec});
runInAction(() => this.data = data);
}FetchService integrates with TrackService via the track option. This records the request
(with timing) in the activity log, viewable in the Admin Console.
// Simple form - just a message
const data = await XH.fetchJson({
url: 'api/portfolios',
track: 'Loaded portfolios'
});
// Full form - with category and additional data
const report = await XH.fetchJson({
url: 'api/reports/generate',
params: {reportId, format},
track: {
message: 'Generated report',
category: 'Reporting',
data: {reportId, format}
}
});The track option automatically captures:
- Elapsed time for the request
- Correlation ID (if enabled on the request)
- LoadSpec metadata (if provided)
For operations that aren't fetch requests, use the Promise.track() extension to record
activity with timing. This is implemented in /promise/ and delegates to TrackService.
// Simple: track a single async operation via the Promise.track() extension
await this.processDataAsync(records)
.track('Processed records');
// Multi-step: compose tracking (with masking, logging, etc.) via the Runner chain on HoistBase
await this.runner()
.linkTo(this.calcTask)
.track({
message: 'Ran portfolio calculation',
category: 'Calculation',
data: {portfolioCount: portfolios.length}
})
.run(async () => {
const inputs = await this.gatherInputsAsync();
return this.runCalculationAsync(inputs);
});Promise.track() automatically:
- Records the start time when the promise begins
- Calculates elapsed time when the promise settles
- Sets severity to 'ERROR' if the promise rejects (and re-throws the error)
- Skips tracking for "routine" exceptions (e.g., user cancellations)
This is useful for tracking operations like local data processing, complex calculations, or coordinated multi-step workflows where you want timing visibility without a fetch request.
@bindable accessor searchQuery = '';
constructor() {
super();
this.addReaction({
track: () => this.searchQuery,
run: () => this.searchAsync(),
debounce: 300
});
}
async searchAsync() {
if (!this.searchQuery) {
runInAction(() => this.results = []);
return;
}
try {
const results = await XH.fetchJson({
url: 'api/search',
params: {q: this.searchQuery},
autoAbortKey: 'search' // Cancels previous search if still pending
});
runInAction(() => this.results = results);
} catch (e) {
// Silently swallow aborted requests - a newer search has taken over
if (e.isFetchAborted) return;
XH.handleException(e);
}
}class PriceModel extends HoistModel {
@managed priceSubscription: WebSocketSubscription;
override onLinked() {
this.priceSubscription = XH.webSocketService.subscribe(
'priceUpdates',
(msg) => this.handlePriceUpdate(msg.data)
);
}
// Subscription auto-destroyed when model destroyed via @managed
}class MyModel extends HoistModel {
get showAdvancedFeatures(): boolean {
return XH.getConf('advancedFeaturesEnabled', false);
}
get maxExportRows(): number {
return XH.getConf('exportConfig', {}).maxRows ?? 10000;
}
}XH.fetchJson() throws on HTTP errors. Handle exceptions appropriately:
// β Wrong: Unhandled exception crashes the app
const data = await XH.fetchJson({url: 'api/data'});
// β
Correct: Handle in doLoadAsync with proper error handling
override async doLoadAsync(loadSpec: LoadSpec) {
try {
const data = await XH.fetchJson({url: 'api/data'}, {loadSpec});
runInAction(() => this.data = data);
} catch (e) {
if (loadSpec.isStale || loadSpec.isAutoRefresh) return;
runInAction(() => this.data = []);
XH.handleException(e, {alertType: 'toast'});
}
}When params is passed without an explicit method, FetchService issues a POST and sends
the params as application/x-www-form-urlencoded in the request body -- not as a URL query
string on a GET. This is rarely what callers intend.
Always specify method: 'GET' (or use XH.fetchService.getJson()) when you intend a GET with
query parameters. Omitting the method changes the verb, the wire format, and the server-side
route hit.
// β Surprising: sends POST with form-encoded params in the body
await XH.fetchJson({url: 'api/users', params: {role: 'admin'}});
// β
Explicit GET with query string ?role=admin
await XH.fetchJson({url: 'api/users', params: {role: 'admin'}, method: 'GET'});
// β
Or use the dedicated getJson() helper
await XH.fetchService.getJson({url: 'api/users', params: {role: 'admin'}});Hoist apps should use FetchService (via XH.fetch(), XH.fetchJson(), XH.postJson()) rather
than the browser's native fetch API. FetchService provides automatic correlation IDs, configurable
timeouts, request deduplication via autoAbortKey, integrated activity tracking, rich exception
handling with server-side details, and loadSpec support for stale request management. Bypassing
it means losing all of these benefits.
// β Wrong: Native fetch bypasses all FetchService enhancements
const response = await fetch('/api/data');
const data = await response.json();
// β
Correct: Use FetchService via XH convenience methods
const data = await XH.fetchJson({url: 'api/data'});Without loadSpec, requests aren't tracked and can't be cancelled when loads become stale:
// β Missing loadSpec
override async doLoadAsync(loadSpec: LoadSpec) {
const data = await XH.fetchJson({url: 'api/data'}); // No tracking
}
// β
Include loadSpec
override async doLoadAsync(loadSpec: LoadSpec) {
const data = await XH.fetchJson({url: 'api/data'}, {loadSpec});
}Configs are app-wide; use preferences for user-specific settings:
// β Wrong: Configs are shared across all users
XH.getConf('userTheme');
// β
Correct: Preferences are per-user
XH.getPref('userTheme');Subscriptions persist until explicitly destroyed:
// β Wrong: Subscription leaks when model is destroyed
const sub = XH.webSocketService.subscribe('updates', handler);
// β
Correct: Use @managed for automatic cleanup
@managed subscription = XH.webSocketService.subscribe('updates', handler);
// β
Also correct: Manual cleanup in destroy()
override destroy() {
this.subscription?.destroy();
super.destroy();
}Services should focus on data operations - fetching, sending, transforming, storing, and processing data. Avoid importing components or rendering UI from services.
// β Wrong: Service imports and renders components
import {tradeConfirmationDialog} from '../desktop/cmp/trade/TradeConfirmationDialog';
class TradeService extends HoistService {
async submitTradeAsync(trade: Trade) {
const confirmed = await tradeConfirmationDialog({trade}); // Tight coupling to desktop
if (confirmed) await XH.postJson({...});
}
}
// β
Correct: Service handles data, caller handles UI
class TradeService extends HoistService {
async submitTradeAsync(trade: Trade) {
return XH.postJson({url: 'api/trades', body: trade});
}
}
// In a Model or Component - UI concerns stay here
async onSubmitClick() {
const confirmed = await XH.confirm({message: 'Submit this trade?'});
if (confirmed) {
await XH.tradeService.submitTradeAsync(this.trade);
XH.successToast('Trade submitted');
}
}Why this matters:
- Services may be shared between desktop and mobile apps - component imports create platform dependencies
- Keeps services focused and testable
- Makes refactoring easier - UI changes don't require service changes
- Cross-platform alerts like
XH.confirm(),XH.toast(), andXH.alert()are acceptable since they're provided by Hoist and work across platforms