Coding Style
Purpose
This guide captures project-specific patterns that recur across the Nrgy.js codebase. General TypeScript and formatting rules are handled by tsconfig and Biome — they are not repeated here.
For documentation rules see docs_requirements.md.
Formatting and Language
- Formatting is enforced by Biome — run
npm run format. Do not hand-tweak whitespace. - TypeScript runs in
strictmode. Type-check withnpm run check. - Prefer named exports for public APIs.
Pattern: Lifecycle Through Scope
Anything that owns a resource (atom, effect, subscription, child controller) should attach it to a Scope. The scope's destroy() is the single teardown point. Do not invent ad-hoc cleanup arrays or boolean flags.
import { createScope } from '@nrgyjs/core';
const scope = createScope();
const value = scope.atom(0); // collected for disposal
scope.effect(value, (v) => console.log(v)); // unsubscribed on destroy
scope.onDestroy(() => closeSocket(socket)); // arbitrary teardown
scope.destroy(); // disposes everything abovescope.atom,scope.effect,scope.syncEffectcreate and register at the same time. Prefer these overatom(...)followed by manual cleanup.scope.add(resource)adopts an existing{ unsubscribe }/{ destroy }/Scope.destroy()is idempotent; calling it twice is not an error.
Pattern: Controllers via declareController
Business logic lives in controllers. A controller declaration receives a context ({ scope, params }) and returns the public service. The returned destroy() is supplied automatically from the scope unless you override it.
import { declareController } from '@nrgyjs/core';
const CounterController = declareController<{ value: () => number }>(
({ scope }) => {
const value = scope.atom(0);
return {
value,
increase: () => value.update((prev) => prev + 1),
};
},
);
const counter = new CounterController();
counter.increase();
counter.destroy();- Allocate all reactive state through the controller's
scopeso it tears down with the controller. - Do not return raw mutable objects from a controller — expose atoms (for reading) and methods (for writing).
- Public surface is the returned service plus the automatic
destroy(). Do not add a separatedispose()orclose().
Pattern: Keep @nrgyjs/core Framework-Agnostic
@nrgyjs/core must not depend on React, RxJS, DOM globals, or any other runtime. Framework integration lives in @nrgyjs/react, @nrgyjs/rxjs, @nrgyjs/rx-effects, and the ditox packages.
- A new abstraction that needs
window, JSX, orObservablegoes into the appropriate integration package — not into core. - If a feature can be expressed with atoms, effects, and scopes, it belongs in core. If it needs framework lifecycles, it does not.
Pattern: Tests Around Observable Behavior
Tests use Vitest, are colocated next to source files (foo.ts ↔ foo.test.ts), and assert externally visible behavior. They should explicitly cover destruction paths, because lifecycle bugs do not surface from happy-path tests.
import { describe, expect, it, vi } from 'vitest';
import { createScope } from './createScope';
import { atom } from '../reactivity/reactivity';
describe('createScope()', () => {
it('disposes collected atoms on destroy', () => {
const scope = createScope();
const value = scope.add(atom(1));
scope.destroy();
value.set(2);
expect(value()).toBe(1); // post-destroy writes are no-ops
});
it('runs registered teardown exactly once', () => {
const scope = createScope();
const teardown = vi.fn();
scope.onDestroy(teardown);
scope.destroy();
scope.destroy(); // idempotent
expect(teardown).toHaveBeenCalledTimes(1);
});
});- Name tests after the observed behavior, not the implementation (
disposes collected atoms on destroy, notcalls subscriptions.next). - For schedulers and effects, prefer
runEffects()oversetTimeout/awaitto keep tests deterministic.
Pattern: Minimal Comments, Useful When Present
- Prefer self-explanatory names and small functions over comments.
- A comment is justified when the why is non-obvious — a hidden invariant, a workaround for an upstream bug, a tricky lifecycle constraint. Do not describe what the code already says.