DEV Community

Cover image for Give Your Data Superpowers: Perfect Autocomplete with NgRx SignalStore
mehdi for The Modern Web

Posted on

Give Your Data Superpowers: Perfect Autocomplete with NgRx SignalStore

Building enterprise Angular applications often starts with the same pattern: fetching raw data objects from a REST API and storing them directly in application state.

However, as applications grow, treating state as passive data containers causes business logic to bleed across components, pipes, and utility files. In this article, we’ll explore how to transform raw data into Smart Objects inside an NgRx SignalStore using Object Enrichment—and how to overcome Angular's Dependency Injection constraints cleanly using runInInjectionContext.


🛠️ Interactive Playground & Code

Want to skip straight to the code? You can explore the full, working implementation in the GitHub Repository or test the live reactivity directly below:


1. The Passive Data Problem

When backend APIs return raw JSON payloads, we typically model them using TypeScript interfaces:

// 📁 user.model.ts
export interface UserDto {
  id: string;
  firstName: string;
  lastName: string;
  roles: string[];
}
Enter fullscreen mode Exit fullscreen mode

Because these objects are strictly data containers with zero behavior, domain logic—like formatting full names or validating roles—gets scattered across component helper methods, utility functions, or template pipes.

This causes two major issues:

  • Poor Discoverability: Developers cannot rely on IDE autocomplete (user.) to discover available domain rules. They must hunt down utility functions or grep the codebase for pipes.
  • Logic Duplication: Basic domain rules get rewritten across multiple components, increasing the risk of bugs when business rules change.

2. Step 1: Smart Domain Objects (Without Dependency Injection)

To solve this, we can adopt a Smart Object approach via Object Enrichment (a Factory Mixin pattern).

Instead of treating User as passive data, we define an enriched interface containing methods, and a factory function that decorates incoming data with self-contained domain behavior.

// 📁 user.model.ts

// 1. Enriched Interface
export interface User extends UserDto {
  fullName(): string;
  hasRole(role: string): boolean;
}

// 2. Factory Function (Object Enrichment)
export function enrichUser(dto: UserDto): User {
  return {
    ...dto,
    fullName() {
      return `${this.firstName}${this.lastName}`;
    },
    hasRole(role: string) {
      return this.roles.includes(role);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The Developer Experience (DX) Win

Now, typing user. in TypeScript or an Angular HTML template immediately surfaces fullName() and hasRole() via IDE autocomplete:

<!-- Instant discoverability in templates -->
<h3>{{ user.fullName() }}</h3>

@if (user.hasRole('ADMIN')) {
  <span class="badge">Admin User</span>
}
Enter fullscreen mode Exit fullscreen mode

Architectural Deep-Dive: Write-Time vs. Read-Time computed()

When integrating smart objects into an NgRx SignalStore, an obvious question arises:

"Why not keep raw data in store state and enrich it on read using a computed() signal?"

// ❌ THE "READ-TIME" ENRICHMENT APPROACH
export const UserStore = signalStore(
  withState({ rawUsers: [] as UserDto[] }),
  withComputed(({ rawUsers }) => ({
    users: computed(() => rawUsers().map(enrichUser)),
  }))
);
Enter fullscreen mode Exit fullscreen mode

While this looks clean, transforming objects inside computed() introduces an architectural leak:

  • ❌ Read-Time (computed): Store holds raw data -> Component A reads users (Smart Object ✅) -> Component B reads store.rawUsers() directly (Passive Data! ❌)
  • ✅ Write-Time (at Source): API Response -> enrichUser() -> Store holds Smart Objects -> Any read path automatically gets Smart Objects ✅

1. Architectural Safety: Enriched by Default

If state holds raw data, nothing stops a developer from accessing store.rawUsers() directly or deriving a new computed signal straight from base state—completely bypassing the enriched methods.

Enriching the payload at write time (right after the API response and immediately before calling patchState) guarantees that every signal, selector, or component consuming the store automatically receives a smart object.

2. Performance Reality

Angular's computed() signal is memoized. It re-evaluates .map() only when rawUsers() updates. Because both write-time mapping and computed() execute exactly once per API payload update, performance is equivalent. Write-time enrichment is purely an architectural choice to enforce data safety.


3. Step 2: Tapping into Singleton Dependencies (Parameter Passing)

Real-world domain logic often requires external state. For instance, determining if a user canEdit() might require checking if the user has an 'ADMIN' role and whether the active session has an 'EDIT_USER' permission in a global PermissionsStore:

// 📁 permissions.store.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class PermissionsStore {
  private readonly permissions = signal<string[]>(['EDIT_USER', 'DELETE_USER']);

  hasPermission(permission: string): boolean {
    return this.permissions().includes(permission);
  }

  togglePermission(permission: string): void {
    const current = this.permissions();
    if (current.includes(permission)) {
      this.permissions.set(current.filter((p) => p !== permission));
    } else {
      this.permissions.set([...current, permission]);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Passing Dependencies as Parameters

The simplest way to give our enrichment function access to PermissionsStore is to pass it explicitly:

// 📁 user.model.ts
export function enrichUser(dto: UserDto, permissionsStore: PermissionsStore): User {
  return {
    ...dto,
    fullName() {
      return `${this.firstName}${this.lastName}`;
    },
    canEdit() {
      return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN');
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Inside our SignalStore, we inject PermissionsStore and thread it into .map():

// 📁 user.store.ts
export const UserStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withMethods((
    store,
    userService = inject(UserService),
    permissionsStore = inject(PermissionsStore)
  ) => ({
    async loadUsers(): Promise<void> {
      patchState(store, { isLoading: true });

      const rawUsers = await firstValueFrom(userService.fetchUsers());
      const enrichedUsers = rawUsers.map((dto) => enrichUser(dto, permissionsStore));

      patchState(store, { users: enrichedUsers, isLoading: false });
    },
  }))
);
Enter fullscreen mode Exit fullscreen mode

The Drawback: Parameter Drilling

As your domain model grows, threading multiple dependencies (AuthService, FeatureFlagStore, Router) through mapping calls creates brittle function signatures. Adding or removing a dependency breaks every mapping call across your codebase.


4. Step 3: Direct inject() & The NG0200 Trap

To eliminate parameter drilling, we can call inject() directly inside the enrichment function:

// 📁 user.model.ts
import { inject } from '@angular/core';
import { PermissionsStore } from './permissions.store';

export interface UserDto {
  id: string;
  firstName: string;
  lastName: string;
  roles: string[];
}

export interface User extends UserDto {
  fullName(): string;
  hasRole(role: string): boolean;
  canEdit(): boolean;
}

export function enrichUser(dto: UserDto): User {
  // Resolved via Angular Injection Context at execution time
  const permissionsStore = inject(PermissionsStore);

  return {
    ...dto,
    fullName() {
      return `${this.firstName}${this.lastName}`;
    },
    hasRole(role: string) {
      return this.roles.includes(role);
    },
    canEdit() {
      return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN');
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Now, the signature is pristine: enrichUser(dto). However, updating your store method to use .map(enrichUser) triggers a runtime crash:

// 📁 user.store.ts
async loadUsers(): Promise<void> {
  patchState(store, { isLoading: true });

  const rawUsers = await firstValueFrom(userService.fetchUsers());

  // 💥 RUNTIME CRASH: NG0200
  const enrichedUsers = rawUsers.map(enrichUser);

  patchState(store, { users: enrichedUsers, isLoading: false });
}
Enter fullscreen mode Exit fullscreen mode

NG0200: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function passed to runInInjectionContext.

Why Does This Crash? (The Microtask Boundary)

Angular’s inject() function relies on an active Injection Context tied strictly to synchronous execution stack frames.

[ Synchronous Store Setup ] ──► Injection Context ACTIVE ✅
            │
            ▼
   await firstValueFrom(...) ──► Microtask Boundary / Async Gap ⏸️
            │
            ▼
[ Async Resume Phase ]      ──► Injection Context LOST ❌ ──► NG0200 Error!
Enter fullscreen mode Exit fullscreen mode

When execution yields at await firstValueFrom(...), Angular’s synchronous injection stack is cleared. When the API response resolves in a later microtask, calling enrichUser(dto) -> inject(PermissionsStore) finds zero active injection context.


5. Step 3.5: The "Closure Factory" Workaround & Its Vulnerability

Developers often attempt to bypass this by creating a factory that captures dependencies synchronously before the await boundary:

// 📁 user.model.ts
export function createEnricher() {
  const permissionsStore = inject(PermissionsStore);

  return (dto: UserDto): User => ({
    ...dto,
    fullName() { return `${this.firstName}${this.lastName}`; },
    canEdit() {
      return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN');
    },
  });
}
Enter fullscreen mode Exit fullscreen mode
// 📁 user.store.ts
async loadUsers(): Promise<void> {
  // Captured synchronously before await ✅
  const enrich = createEnricher();

  const rawUsers = await firstValueFrom(userService.fetchUsers());
  const enrichedUsers = rawUsers.map(enrich);

  patchState(store, { users: enrichedUsers, isLoading: false });
}
Enter fullscreen mode Exit fullscreen mode

The Refactoring Trap

This workaround relies on an implicit timing rule. If another developer refactors the method months later and inlines const enrich = createEnricher() after the await boundary, the code still compiles cleanly but crashes in production with NG0200.


6. Step 4: The Final Solution — runInInjectionContext

To make context restoration explicit and 100% refactor-proof, we inject Angular's EnvironmentInjector once during store setup and wrap the write-time transformation inside runInInjectionContext:

// 📁 user.store.ts
import { EnvironmentInjector, inject, runInInjectionContext } from '@angular/core';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { firstValueFrom } from 'rxjs';
import { User, UserDto, enrichUser } from './user.model';
import { UserService } from './user.service';

interface UserState {
  users: User[];
  isLoading: boolean;
}

const initialState: UserState = {
  users: [],
  isLoading: false,
};

export const UserStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withMethods((
    store,
    userService = inject(UserService),
    injector = inject(EnvironmentInjector) // 👈 1. Grab EnvironmentInjector
  ) => ({
    async loadUsers(): Promise<void> {
      patchState(store, { isLoading: true });

      try {
        // 2. Async API Call (Injection context drops after await)
        const rawUsers: UserDto[] = await firstValueFrom(userService.fetchUsers());

        // 3. Explicitly restore Injection Context so enrichUser can call inject() without params
        const enrichedUsers = runInInjectionContext(injector, () =>
          rawUsers.map(enrichUser)
        );

        patchState(store, { users: enrichedUsers, isLoading: false });
      } catch (error) {
        patchState(store, { isLoading: false });
        console.error('Failed to load users:', error);
      }
    },
  }))
);
Enter fullscreen mode Exit fullscreen mode

Integration with @ngrx/signals/entities (withEntities)

If your SignalStore uses entity management, this pattern plugs directly into setAllEntities:

import { EnvironmentInjector, inject, runInInjectionContext } from '@angular/core';
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { setAllEntities, withEntities } from '@ngrx/signals/entities';
import { firstValueFrom } from 'rxjs';
import { User, UserDto, enrichUser } from './user.model';
import { UserService } from './user.service';

export const UserEntityStore = signalStore(
  { providedIn: 'root' },
  withEntities<User>(),
  withMethods((
    store,
    userService = inject(UserService),
    injector = inject(EnvironmentInjector)
  ) => ({
    async loadUsers(): Promise<void> {
      const rawUsers: UserDto[] = await firstValueFrom(userService.fetchUsers());

      const enrichedUsers = runInInjectionContext(injector, () =>
        rawUsers.map(enrichUser)
      );

      patchState(store, setAllEntities(enrichedUsers));
    },
  }))
);
Enter fullscreen mode Exit fullscreen mode

7. Section 5: DI Scope Rules & The Template Escape Hatch

Understanding how Angular’s Dependency Injection Hierarchy works is vital when using inject() inside smart objects:

An injection context can only resolve dependencies from its current level or higher up the DI tree toward the Root Injector.

[ Root / Environment Injector ] ──► Root Singletons (AuthService, Global Stores)
           │
           ▼
[ Component / Element Injector ] ─► Local Component State & Scoped Services
Enter fullscreen mode Exit fullscreen mode
  • Root Stores (providedIn: 'root'): enrichUser() can only call inject() on services provided in root. Attempting to inject a service scoped to a local component provider will throw a NullInjectorError.
  • Component Stores (providers: [UserStore]): Passing a component's ElementInjector into runInInjectionContext allows enrichUser() to resolve both root singletons and local component-scoped services.

What If a Method Needs Component Context?

If a domain method on a root entity needs to evaluate rules against component-scoped state (e.g., local form state or route parameters), pass Angular's Injector as an optional parameter to the domain method:

// 📁 user.model.ts
import { Injector, inject, runInInjectionContext } from '@angular/core';
import { PermissionsStore } from './permissions.store';
import { LocalComponentService } from './local-component.service';

export interface UserDto {
  id: string;
  firstName: string;
  lastName: string;
  roles: string[];
}

export interface User extends UserDto {
  fullName(): string;
  hasRole(role: string): boolean;
  canEdit(injector?: Injector): boolean;
}

export function enrichUser(dto: UserDto): User {
  const permissionsStore = inject(PermissionsStore);

  return {
    ...dto,
    fullName() {
      return `${this.firstName}${this.lastName}`;
    },
    hasRole(role: string) {
      return this.roles.includes(role);
    },
    canEdit(injector?: Injector) {
      if (injector) {
        return runInInjectionContext(injector, () => {
          const localContext = inject(LocalComponentService, { optional: true });
          if (localContext && !localContext.isEditingAllowed()) {
            return false;
          }
          return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN');
        });
      }

      return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN');
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Expose the Injector in your component and pass it directly from the template binding:

// 📁 app.component.ts
import { Component, OnInit, Injector, inject } from '@angular/core';
import { UserStore } from './user.store';
import { PermissionsStore } from './permissions.store';

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <div style="font-family: system-ui, -apple-system, sans-serif; padding: 24px; max-width: 700px; margin: 0 auto;">
      <h2>🚀 Step 4: Direct inject() with runInInjectionContext</h2>

      <div style="margin-bottom: 20px; padding: 12px; background: #f0f4f8; border-radius: 8px;">
        <button (click)="permissionsStore.togglePermission('EDIT_USER')">
          Toggle 'EDIT_USER' Permission
        </button>
        <span>
          Status: <strong>{{ permissionsStore.hasPermission('EDIT_USER') ? 'ALLOWED ✅' : 'REVOKED ❌' }}</strong>
        </span>
      </div>

      @if (userStore.isLoading()) {
        <p>Loading users...</p>
      } @else {
        <div>
          @for (user of userStore.users(); track user.id) {
            <div>
              <h3>{{ user.fullName() }}</h3>
              <p>Roles: {{ user.roles.join(', ') }}</p>

              <!-- Pass component injector directly from template binding -->
              @if (user.canEdit(injector)) {
                <button>✏️ Edit User</button>
              } @else {
                <span>No edit permission</span>
              }
            </div>
          }
        </div>
      }
    </div>
  `,
})
export class App implements OnInit {
  readonly userStore = inject(UserStore);
  readonly permissionsStore = inject(PermissionsStore);

  // Expose local ElementInjector to template
  protected readonly injector = inject(Injector);

  ngOnInit(): void {
    this.userStore.loadUsers();
  }
}
Enter fullscreen mode Exit fullscreen mode

8. Architectural Guidance: Keep Domain Models Generic

While passing a local Injector into templates serves as a useful escape hatch, treat it as an exception rather than a primary pattern.

Keep smart objects focused strictly on core domain business rules and providedIn: 'root' dependencies.

If logic relies heavily on local component state, keep that logic inside a component-scoped store or view model. Because component-level stores sit lower in Angular's DI hierarchy, they can freely inject root stores, access smart objects, and combine them with local UI state cleanly—without polluting the core object with component-level dependencies.


Conclusion

Combining Write-Time Object Enrichment with Angular's runInInjectionContext provides a robust architecture for enterprise Angular applications:

  1. Passive Data becomes Smart Objects, co-locating business logic with data.
  2. SignalStore state is Enriched by Default, ensuring downstream selectors and components receive safe, typed domain models.
  3. Parameter Drilling is Eliminated, keeping mapper signatures clean with direct inject() calls.
  4. DI Boundaries are Safe, avoiding NG0200 async drops across HTTP boundaries.

Top comments (0)