Best Practices
This guide covers the critical patterns and rules for each CDF layer. Follow these to avoid memory leaks, state inconsistencies, and circular dependencies.
Adaptors & Transformers
Adaptors integrate an SDK into CDF. Transformers convert SDK models into CDF entities.
Transformer rules
Rule: Keep transformers pure (no side effects)
A transformer is a plain function: receive SDK model → return CDF entity. No subscriptions, config, or SDK init here. Those belong in the adaptor class.
// ✅ Correct — pure transformation
function transformUser(sdkUser: SDKUser): ESPCDFUser {
return new ESPCDFUser({
id: sdkUser.id,
name: sdkUser.name,
operations: createUserOps(sdkUser)
});
}
// ❌ Incorrect — side effect (SDK call)
function transformUser(sdkUser: SDKUser): ESPCDFUser {
sdk.subscribe(sdkUser.id); // Side effect!
return new ESPCDFUser({ ... });
}
Rule: Populate all operations at transform time
The operations object must be complete before returning. Never add or replace methods after the entity enters a store.
// ✅ Correct — all operations ready
const entity = new ESPCDFNode({
id: nodeId,
operations: {
getParams: () => sdk.getParams(nodeId),
updateParam: (param) => sdk.updateParam(nodeId, param),
delete: () => sdk.deleteNode(nodeId)
}
});
// ❌ Incorrect — adding operations later
const entity = new ESPCDFNode({ id: nodeId, operations: {} });
entity.operations.getParams = () => sdk.getParams(nodeId); // Too late!
Rule: Register _raw sync in every node transformer
Keeps _raw and observable state in sync. Omitting this causes stale SDK calls later.
const node = new ESPCDFNode({ id: nodeId, ... });
node.onPropertyChange(createPropertyChangeSyncCallback(sdkNode)); // Required!
return node;
Rule: Return CDF entities, never SDK types
All adaptor methods (authenticate, getUser, getNodes, etc.) must return ESPCDF* types. SDK types stop at the adaptor boundary.
// ✅ Correct
async getUser(): Promise<ESPCDFUser> {
const sdkUser = await sdk.getUser();
return transformUser(sdkUser);
}
// ❌ Incorrect — SDK type crosses boundary
async getUser(): Promise<SDKUser> {
return sdk.getUser(); // Now UI code holds SDK types!
}
Adaptor design
Rule: One adaptor per SDK
Each adaptor owns exactly one SDK. Mixing SDKs in one adaptor couples their lifecycles and breaks runtime switching. Create a new adaptor class for each ecosystem.
// ✅ Correct — separate adaptors
export class RainMakerAdaptor extends ESPSDKAdaptor { ... }
export class ZigbeeAdaptor extends ESPSDKAdaptor { ... }
// ❌ Incorrect — mixed SDKs
export class UniversalAdaptor extends ESPSDKAdaptor {
async authenticate() {
if (this.config.sdk === 'rainmaker') return rainmakerSDK.login(...);
if (this.config.sdk === 'zigbee') return zigbeeSDK.login(...);
}
}
Rule: Centralize identifier constants
Keep all _identifier strings and the SDKIdentifier union type in one file (example). One source of truth for config, UI, and adaptor code.
Rule: Register adaptors before initCDF
Register all adaptors and set the active one before initializing CDF. You can switch dynamically later via AdaptorRegistry if your app supports it.
const registry = new AdaptorRegistry();
registry.register("rainmaker", new RainMakerAdaptor());
registry.register("zigbee", new ZigbeeAdaptor());
registry.setActive("rainmaker");
const espCDF = initCDF({ sdkAdaptorRegistry: registry });
Entities
Entities are the observable models returned by stores. They pair observable data with operations that modify that data.
Operation handling
Rule: Entities delegate to operations, never mutate themselves
An entity method calls operations.<method>(), emits the result, and stops. Store mutations belong in synchronizers, not entities.
// ✅ Correct — delegate and emit
async delete(): Promise<ESPCDFAPIResponse> {
return this.runAndEmit("delete", () => this.operations.delete(), () => this);
}
// ❌ Incorrect — entity mutating itself
async delete(): Promise<void> {
await this.operations.delete();
this.deleted = true; // Synchronizer's job, not entity's!
}
Why? If entities mutate themselves, the synchronizer doesn't know about it, and _raw falls out of sync.
Rule: Always use runAndEmit
This helper emits operation events on both success and failure. Without it, synchronizers miss failures and can't track inconsistent state.
Rule: Never inject store references into entities
Entities hold operations and _raw only. A store reference creates a circular dependency and makes isolated testing impossible.
// ✅ Correct — no store reference
const node = new ESPCDFNode({
id: "abc",
operations: { ... },
_raw: sdkNode
});
// ❌ Incorrect — circular dependency
const node = new ESPCDFNode({
id: "abc",
operations: { ... },
_raw: sdkNode,
store: nodeStore // Creates cycle + testing nightmare
});
Synchronizers
Synchronizers watch entity events and update store state. They're the only place where mutations happen.
Subscription lifecycle
The critical rule: symmetry between attach and detach prevents memory leaks.
Rule 1: Detach before re-attaching
When an entity is re-attached, call detach first to discard the old subscription.
attach(group: ESPCDFGroup) {
this.detach(group.id); // Clean up old subscription if it exists
// Now create new subscription...
}
Rule 2: Store unsubscribe functions, not entity references
Keep a Map<entityId, unsubscribeFunction> and call the function in detach.
private unsubscribes = new Map<string, () => void>();
attach(group: ESPCDFGroup) {
const unsubscribe = group.onChange(() => {
// Handle change...
});
this.unsubscribes.set(group.id, unsubscribe);
}
detach(groupId: string) {
const unsubscribe = this.unsubscribes.get(groupId);
if (unsubscribe) {
unsubscribe();
this.unsubscribes.delete(groupId);
}
}
Rule 3: Recursively attach children
If an entity has children (subGroups, child nodes), attach them in the same attach call.
Rule 4: Implement dispose() for cleanup
Tear down all subscriptions at once. Call on logout or reset.
dispose(): void {
this.unsubscribes.forEach(unsubscribe => unsubscribe());
this.unsubscribes.clear();
// Repeat for any other subscription maps
}
handleOperation design
Rule: All mutation logic lives in handleOperation (nowhere else)
Stores expose @action methods; synchronizers call them. Entities don't mutate themselves, stores don't subscribe to entities.
Rule: One case per operation, one concern per case
Keep each branch focused. If an operation affects multiple stores (e.g., getNodes fills both GroupStore and NodeStore), make that coordination explicit and contained in that case.
// ✅ Correct — clear single concern
case "nodeAdded":
const node = this.createAndAttachNode(data);
this.nodesByIdMap.set(node.id, node);
break;
// ❌ Incorrect — multiple concerns mixed
case "nodeAdded":
const node = this.createAndAttachNode(data);
this.nodesByIdMap.set(node.id, node);
espCDF.subscriptionStore.listen(node); // Shouldn't be here
navigator.push("/node/" + node.id); // And this
Rule: Never subscribe to entities from stores
Subscriptions belong in synchronizers. Stores that hold entity subscriptions bypass the mediator pattern and scatter mutation logic.
// ❌ Incorrect — store subscribing to entity
addNode(node) {
node.onChange(() => {
this.nodesByIdMap.set(node.id, node); // Mutation happens here
});
}
// ✅ Correct — synchronizer subscribes
synchronizer.attach(node) {
node.onChange(() => {
store.handleOperation("nodeUpdated", { nodeId: node.id, ... });
});
}
Rule: Log failures without patching state
On failure, log it. Never apply partial updates — inconsistent state is worse than no state.
case "updateFailed":
console.error(`Update failed: ${data.error}`);
// Don't do: node.status = "error"; // Partial state!
break;
Stores & MobX Reactivity
Inserting entities
Rule: Always use add* or set*List store methods
These methods make the entity observable AND attach the synchronizer. Bypassing them skips both.
// ✅ Correct — uses store method
store.addNode(node);
// Internally: makeEverythingObservable(node, ...) + synchronizer.attach(node)
// ❌ Incorrect — direct map write
store.nodesByIdMap.set(node.id, node);
// Entity isn't observable! Synchronizer never attaches!
Rule: Never mutate store maps from UI code
All mutations go through entity methods. Only synchronizers call @action store methods.
Making properties observable
makeEverythingObservable (in src/utils/common.ts) recursively observes nested objects while skipping your exclusion set:
makeEverythingObservable(entity, new Set(["_raw", "operations"]));
Rule: Always exclude _raw and operations
_rawis an SDK object with non-serializable internalsoperationsholds SDK function wrappers- Neither needs MobX tracking
Rule: Don't call it twice on the same object
It checks isObservableObject and skips if already observable, but calling it twice signals the entity is being inserted wrong.
UI components
Rule: Wrap every store-reading component with observer
Without it, the component captures the value at render time and never updates.
// ✅ Correct — will re-render when connected changes
const NodeCard = observer(function NodeCard({ node }: { node: ESPCDFNode }) {
return <Text>{node.id} — {node.connectivityStatus?.connected ? "online" : "offline"}</Text>;
});
// ❌ Incorrect — won't re-render
function NodeCard({ node }: { node: ESPCDFNode }) {
return <Text>{node.id} — {node.connectivityStatus?.connected ? "online" : "offline"}</Text>;
}
Rule: Prefer granular observer components
Pass individual entities or params to small observer sub-components. MobX tracks at the property level — a ParamToggle that reads only param.value re-renders only when that value changes.
Rule: Never read _raw or operations in JSX
These aren't observable and expose SDK internals. Render from CDF entity fields only.
Logout & Cleanup
When logging out, the order is critical:
async function handleLogout(espCDF: ESPCDF) {
const user = espCDF.userStore.user;
if (!user) return;
// 1. Stop push event callbacks first
await user.unsubscribeFromNodeUpdates();
// 2. Then logout (adaptor tears down SDK subscriptions)
await user.logout();
// 3. UserStoreSynchronizer clears all domain stores automatically
}
What happens:
| Step | What CDF does |
|---|---|
unsubscribeFromNodeUpdates() | Stops receiving cloud push events |
logout() operation | Adaptor tears down SDK session and subscriptions |
UserStoreSynchronizer | Detaches all synchronizers and clears stores |
Don't call store.clear() manually — UserStoreSynchronizer does it on logout success.
See Also
Core architecture
- Architecture — layer model and design philosophy
- Adaptor Registry — adaptor and transformer patterns
Implementation details
- Entities — entity API and event patterns
- Domain Stores — each store's API and reactivity