Angular Adapter Design
Low-level design for @stateloom/angular — the Angular framework adapter. Covers the four bridging functions (toObservable, toAngularSignal, fromObservable, injectStore), the InjectionToken-based DI system for SSR scope, and the dual RxJS/Signal integration strategy.
Overview
The Angular adapter provides four bridging functions that connect Subscribable<T> from @stateloom/core to Angular's two reactive systems: RxJS Observables and Angular Signals. It also provides dependency injection integration for SSR scope. Unlike other adapters that target a single reactive primitive, the Angular adapter bridges to both systems because Angular applications use both idioms. The adapter is pure bridging code (~250 lines total).
Bridging Strategy
Angular's ecosystem uses two reactive systems: RxJS Observables (for async pipes, operators, and services) and Angular Signals (for template bindings and signal-based components, Angular 16+). The adapter provides bridges for both directions:
| Function | Direction | Use Case |
|---|---|---|
toObservable(sub) | StateLoom -> RxJS | Pipe chains, async pipe, RxJS operators |
toAngularSignal(sub) | StateLoom -> Angular Signal | Template bindings, signal-based components |
fromObservable(obs$, init) | RxJS -> StateLoom | Bridge existing RxJS streams into StateLoom |
injectStore(store, sel?) | StateLoom Store -> Angular Signal | Store integration with selector memoization |
Implementation Details
toObservable
Converts a Subscribable<T> to an RxJS Observable<T> with BehaviorSubject-like semantics (immediate emission):
export function toObservable<T>(subscribable: Subscribable<T>): Observable<T> {
return new Observable<T>((subscriber) => {
subscriber.next(subscribable.get()); // emit current value
const unsubscribe = subscribable.subscribe((value) => {
subscriber.next(value); // emit changes
});
return unsubscribe;
});
}No injection context required: This is a pure function. It can be called anywhere — in services, in components, or outside Angular entirely. The Observable is cold — each subscriber gets its own subscription to the StateLoom source, starting with the current value.
Immediate emission: Like a BehaviorSubject, the Observable emits the current value on subscription. This ensures | async pipe consumers see a value immediately rather than showing null until the first change.
Teardown: The Observable's teardown logic (return unsubscribe) cleanly disconnects from StateLoom when the Observable subscription is unsubscribed. This integrates with Angular's takeUntilDestroyed() and other RxJS lifecycle patterns.
toAngularSignal
Bridges a Subscribable<T> to Angular's native signal system with automatic cleanup:
export function toAngularSignal<T>(
subscribable: Subscribable<T>,
options?: ToAngularSignalOptions<T>,
): AngularSignal<T> {
const destroyRef = inject(DestroyRef);
const sig = options?.equal
? angularSignal(subscribable.get(), { equal: options.equal })
: angularSignal(subscribable.get());
const unsubscribe = subscribable.subscribe((value) => {
sig.set(value);
});
destroyRef.onDestroy(unsubscribe);
return sig.asReadonly();
}Injection context required: inject(DestroyRef) retrieves the component's or injector's DestroyRef. This means toAngularSignal must be called in a constructor, field initializer, or runInInjectionContext.
Custom equality: The equal option controls Angular's signal equality check. When provided, the Angular signal only triggers change detection when equal() returns false. This is separate from StateLoom's equality semantics — it controls the Angular-side behavior.
Read-only return: sig.asReadonly() returns a read-only Angular signal. State changes should flow through the StateLoom source, not by setting the Angular signal directly. This prevents bypassing StateLoom's middleware and notification system.
Cleanup via DestroyRef: DestroyRef.onDestroy registers a callback that fires when the component or injector is destroyed. This automatically unsubscribes from the StateLoom source — no manual cleanup needed.
fromObservable
Converts an RxJS Observable<T> into a StateLoom Subscribable<T> with reference counting:
export function fromObservable<T>(observable$: Observable<T>, initialValue: T): Subscribable<T> {
let currentValue = initialValue;
let subscription: Subscription | undefined;
const listeners = new Set<(value: T) => void>();
function connect(): void {
if (subscription) return;
subscription = observable$.subscribe({
next(value) {
currentValue = value;
for (const listener of listeners) {
listener(value);
}
},
error() {
// Silently swallowed; use catchError upstream
},
});
}
function disconnect(): void {
if (listeners.size > 0 || !subscription) return;
subscription.unsubscribe();
subscription = undefined;
}
return {
get: () => currentValue,
subscribe(callback) {
listeners.add(callback);
connect();
return () => {
listeners.delete(callback);
disconnect();
};
},
};
}Reference counting: The Observable is subscribed lazily when the first StateLoom listener attaches. It is unsubscribed when the last listener detaches. This prevents resource leaks and aligns with RxJS's subscription model.
No injection context required: Like toObservable, this is a pure function. The returned Subscribable<T> can be used with any StateLoom consumer — framework hooks, store middleware, or other adapters.
Error handling: Observable errors are silently swallowed. The subscribable retains its last value. Consumers should use RxJS's catchError operator to handle errors before bridging.
injectStore
Bridges a StateLoom store to an Angular signal with optional selector and equality:
export function injectStore<T, U = T>(
store: StoreApi<T>,
selectorOrOptions?: ((state: T) => U) | InjectStoreOptions<T>,
options?: InjectStoreOptions<U>,
): AngularSignal<U> {
const destroyRef = inject(DestroyRef);
const hasSelector = typeof selectorOrOptions === 'function';
const selector = hasSelector ? (selectorOrOptions as (state: T) => U) : undefined;
const resolvedOptions = hasSelector
? options
: (selectorOrOptions as InjectStoreOptions<U> | undefined);
const equal = resolvedOptions?.equal;
const initialState = store.getState();
const initialValue = selector ? selector(initialState) : (initialState as unknown as U);
const sig = equal ? angularSignal(initialValue, { equal }) : angularSignal(initialValue);
let previousSelected = initialValue;
const unsubscribe = store.subscribe((state) => {
const nextSelected = selector ? selector(state) : (state as unknown as U);
if (!Object.is(nextSelected, previousSelected)) {
previousSelected = nextSelected;
sig.set(nextSelected);
}
});
destroyRef.onDestroy(unsubscribe);
return sig.asReadonly();
}Overloaded API: injectStore accepts either (store), (store, options), or (store, selector, options). The implementation resolves the overload by checking typeof selectorOrOptions === 'function'.
Selector memoization: The equality check (Object.is) prevents unnecessary Angular change detection cycles when the selected value hasn't changed. When the full store state changes but the selected slice is equal, the Angular signal is not updated.
Two-layer equality: The Object.is check prevents updating the Angular signal for equal selected values. The optional equal function on the Angular signal provides a second layer of control over Angular's change detection. Most consumers only need the first layer.
SSR Scope Integration
The Angular adapter uses Angular's InjectionToken system for scope injection:
const STATELOOM_SCOPE = new InjectionToken<Scope>('STATELOOM_SCOPE');
function provideStateloomScope(scope: Scope): EnvironmentProviders {
return makeEnvironmentProviders([{ provide: STATELOOM_SCOPE, useValue: scope }]);
}
function injectScope(): Scope | undefined {
return inject(STATELOOM_SCOPE, { optional: true }) ?? undefined;
}Provider Configuration
provideStateloomScope returns EnvironmentProviders for use in bootstrapApplication or route-level providers:
bootstrapApplication(AppComponent, {
providers: [provideStateloomScope(createScope())],
});Why EnvironmentProviders
makeEnvironmentProviders restricts where the provider can be used — only at the environment level (application bootstrap, route configuration), not in component providers arrays. This ensures the scope is a singleton per application (or per route), preventing accidental per-component scope creation that would break SSR isolation.
Optional Injection
inject(STATELOOM_SCOPE, { optional: true }) returns null if no provider exists. The ?? undefined normalizes null to undefined, matching the Scope | undefined return type used by other adapters.
Design Decisions
Why Multiple Bridging Functions Instead of One
Angular's ecosystem genuinely uses both RxJS and Signals. An Angular component might use toAngularSignal for template bindings and toObservable for a service that combines the signal with other RxJS streams via combineLatest. Providing both paths avoids forcing developers to choose or to add their own bridge layer.
fromObservable exists for the reverse direction — applications with existing RxJS state (e.g., NgRx selectors, HTTP services) can bridge into StateLoom's universal contract without rewriting their RxJS code.
Why injectStore Uses store.subscribe Instead of effect()
Unlike the Vue and Solid adapters, injectStore uses store.subscribe() directly. This is sufficient because stores always produce new object references on state changes (via Object.assign), so there are no computed-to-computed chains to miss. The simpler approach avoids importing effect() from @stateloom/core and keeps the subscription model straightforward.
Why toAngularSignal Uses subscribe() Instead of effect()
Same reasoning as injectStore — subscribe() catches all notifications from any Subscribable<T>. For computed signals, the subscribe callback fires after the computed refreshes. The toAngularSignal function doesn't need to trigger refreshes via effect() because the StateLoom graph handles this internally before notifying subscribers.
Why toObservable Emits Immediately
Angular's async pipe initially renders null if the Observable hasn't emitted. Since StateLoom sources always have a current value (accessible via get()), emitting immediately ensures no flash of null in templates. This matches BehaviorSubject semantics that Angular developers expect.
Why fromObservable Swallows Errors
RxJS Observables can error, but StateLoom's Subscribable contract has no error channel. Rather than introducing a new error-handling mechanism, fromObservable silently retains the last value on error. This pushes error handling to the RxJS side (via catchError) where developers already have mature tools for it.
Why DestroyRef Instead of takeUntilDestroyed
DestroyRef.onDestroy is a simpler mechanism than takeUntilDestroyed (which creates a Subject and pipes it through RxJS). Since the adapter is bridging to a plain subscribe callback (not an Observable), DestroyRef.onDestroy is the natural fit. It avoids importing extra RxJS operators.
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Selector memoization | Object.is check before Angular signal update | O(1) per state change |
| Two-layer equality | StateLoom equality + optional Angular equal | Customizable per use case |
| Reference counting | fromObservable lazy connect/disconnect | Zero Observable subscriptions when idle |
| DestroyRef cleanup | Automatic unsubscribe on component destroy | No manual teardown |
| No effect() overhead | Direct subscribe() — no graph nodes created | Simpler subscription model |
| Cold Observable | toObservable creates per-subscriber subscriptions | Standard RxJS behavior |
| Adapter size | ~250 lines total | Tree-shakeable per function |
Cross-References
- Adapters Overview — cross-cutting adapter patterns and adapter contract
- Architecture Overview — where adapters fit in the layer structure
- Core Design —
Subscribable<T>contract that all bridging functions consume - Store Design —
StoreApi<T>thatinjectStoreconsumes - Atom Design — atom APIs bridgeable via
toAngularSignal/toObservable - Proxy Design — proxy APIs bridgeable via
toAngularSignal/toObservable - API Reference:
@stateloom/angular— consumer-facing documentation - Angular Guide — framework adoption guide