Skip to content

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 strict mode. Type-check with npm 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.

ts
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 above
  • scope.atom, scope.effect, scope.syncEffect create and register at the same time. Prefer these over atom(...) 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.

ts
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 scope so 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 separate dispose() or close().

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, or Observable goes 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.tsfoo.test.ts), and assert externally visible behavior. They should explicitly cover destruction paths, because lifecycle bugs do not surface from happy-path tests.

ts
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, not calls subscriptions.next).
  • For schedulers and effects, prefer runEffects() over setTimeout / await to 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.