본문으로 건너뛰기
cccskills
무료GitHub에 공개

angular-state-and-rx

State management for Angular 18-21: signals (signal/computed/effect), services-as-state, NgRx Store + Effects + Selectors, NgRx Component Store, NgRx Signals (newer signal-based store). RxJS essentials — operators, async pipe, takeUntilDestroyed, signal/observable interop. Use this skill to: - Pick the right state tool (signals / services / NgRx variant / vue-query equivalent). - Use signals correctly (signal/computed/effect — when each). - Build a Pinia-style service-as-state singleton. - Set up NgRx Store + Effects + Selectors. - Use RxJS without leaking subscriptions (async pipe, takeUntilDestroyed, Subject patterns). - Bridge signals ↔ observables via toSignal / toObservable. Do NOT use this skill for: - General Angular conventions (see angular-conventions). - Routing state (see angular-routing). - Form state (see angular-forms). - Testing state (see angular-testing).

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

설치 방법 보기

포함된 파일(1)

  • SKILL.md16.3 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

Angular State + RxJS Patterns

Modern Angular era (17+) introduces signals as a first-class reactivity primitive alongside RxJS. Both have a place — signals для component state, RxJS для async streams. Plus optional NgRx variants for complex apps.

Decision tree

NeedTool
Local component state (count, isOpen)signal()
Computed from other statecomputed()
Side effects on signal changeeffect() (sparingly)
Cross-component shared state@Injectable({ providedIn: 'root' }) service з signals
Server data with cachingTanStack Query Angular OR custom RxJS service з shareReplay
Complex domain state з time-travel debuggingNgRx Store + Effects + Selectors
Lightweight per-component reactive storeNgRx Component Store
Signal-based store API@ngrx/signals (Angular 17+)
URL stateActivatedRoute snapshot / queryParams

Detect what's installed; mirror project's choice; never introduce a new state lib without BA approval.

Signals (Angular 17+)

import { signal, computed, effect } from '@angular/core';

count = signal(0);
double = computed(() => this.count() * 2);

constructor() {
  effect(() => {
    console.log('count is now', this.count());
  });
}

increment() {
  this.count.update((c) => c + 1);          // functional update — best for derivations
}

set(value: number) {
  this.count.set(value);                     // direct set
}

reset() {
  this.count.set(0);
}

signal(initial) vs computed(fn) vs effect(fn)

  • signal() — writable, returns a function. Read with signal(), write with signal.set() or signal.update().
  • computed() — read-only, derived from other signals. Auto-tracks dependencies (calls inside the function are observed). Memoized — recomputes only when deps change.
  • effect() — runs after component init and re-runs when read signals change. Use SPARINGLY — most reactivity flows through templates (auto-track via {{ signal() }}).

When NOT to use effect()

  • ❌ For derived state — use computed().
  • ❌ For state synchronization — usually a sign of bad architecture.
  • ❌ For form-control updates — Angular Forms have their own reactivity.
  • ✅ For non-Angular DOM access (e.g., third-party charting library that needs imperative updates).
  • ✅ For logging / analytics on state changes.

Signal inputs/outputs (Angular 17.1+)

import { input, output, computed } from '@angular/core';

@Component({ ... })
export class UserCardComponent {
  user = input.required<User>();              // required input — InputSignal<User>
  showActions = input(false);                  // optional with default — InputSignal<boolean>
  delete = output<string>();                   // OutputEmitterRef<string>

  isAdmin = computed(() => this.user().role === 'admin');

  onDelete() {
    this.delete.emit(this.user().id);
  }
}

For new components in Angular 17.1+, prefer input() / output() over @Input() / @Output() decorators. Mirror existing project style if all components still use decorators.

Services as state (singleton pattern)

// users.service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { User } from './user.model';

@Injectable({ providedIn: 'root' })
export class UsersService {
  private http = inject(HttpClient);

  // Private writable signals
  private _users = signal<User[]>([]);
  private _loading = signal(false);
  private _error = signal<string | null>(null);

  // Public readonly signals
  users = this._users.asReadonly();
  loading = this._loading.asReadonly();
  error = this._error.asReadonly();

  // Public computed
  count = computed(() => this._users().length);
  hasError = computed(() => this._error() !== null);

  async loadUsers() {
    this._loading.set(true);
    this._error.set(null);
    try {
      const users = await firstValueFrom(this.http.get<User[]>('/api/users'));
      this._users.set(users);
    } catch (err) {
      this._error.set(err instanceof Error ? err.message : 'Failed to load');
    } finally {
      this._loading.set(false);
    }
  }

  addUser(user: User) {
    this._users.update((users) => [...users, user]);
  }

  removeUser(id: string) {
    this._users.update((users) => users.filter((u) => u.id !== id));
  }
}

asReadonly() exposes a read-only view — consumers can't .set() on it. Internal mutations stay encapsulated.

Component consumers:

@Component({
  template: `
    @if (usersService.loading()) {
      <p>Loading...</p>
    } @else {
      <p>{{ usersService.count() }} users</p>
      <ul>
        @for (user of usersService.users(); track user.id) {
          <li>{{ user.name }}</li>
        }
      </ul>
    }
  `
})
export class UsersListComponent {
  usersService = inject(UsersService);

  ngOnInit() {
    this.usersService.loadUsers();
  }
}

RxJS essentials

Angular still uses RxJS heavily — HttpClient returns Observables, ActivatedRoute params/queryParams are Observables, NgRx Effects are Observables.

Common operators

import { map, filter, switchMap, mergeMap, exhaustMap, debounceTime, distinctUntilChanged, tap, catchError, finalize, takeUntil, take, shareReplay } from 'rxjs';

this.search$.pipe(
  debounceTime(300),                     // wait 300ms after last input
  distinctUntilChanged(),                // skip duplicates
  switchMap((query) => this.searchService.search(query)),  // cancel prior on new input
  catchError((err) => of([])),           // error fallback
);
OperatorUse
map(fn)Transform values
filter(fn)Keep matching values
tap(fn)Side effect (logging) without changing stream
switchMap(fn)Cancel previous inner observable on new outer (search, autocomplete)
mergeMap(fn)Run all inner observables in parallel
concatMap(fn)Sequential — wait for prior to complete
exhaustMap(fn)Ignore new outer while inner pending (login, save buttons)
debounceTime(ms)Wait quiet period before emitting
distinctUntilChanged()Skip consecutive duplicates
take(n)Take first n then complete
takeUntil(notifier$)Stop when notifier emits (manual cleanup pattern)
shareReplay({ bufferSize: 1, refCount: true })Cache last value for late subscribers (HTTP caching pattern)
catchError(fn)Recover from errors
finalize(fn)Cleanup on complete or error

async pipe in templates

@if (users$ | async; as users) {
  @for (user of users; track user.id) {
    <p>{{ user.name }}</p>
  }
} @else {
  <p>Loading...</p>
}

async pipe handles subscribe + unsubscribe automatically. Always prefer over manual subscribe() in component classes.

takeUntilDestroyed() (Angular 16+)

For when you need subscribe() directly:

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { inject, Component, OnInit } from '@angular/core';

@Component({ ... })
export class UserDetailComponent implements OnInit {
  private route = inject(ActivatedRoute);

  ngOnInit() {
    this.route.params
      .pipe(takeUntilDestroyed())          // automatic cleanup tied to component
      .subscribe((params) => {
        // ...
      });
  }
}

takeUntilDestroyed() MUST be called in injection context — class field initializer or constructor. If called inside ngOnInit(), you must pass an explicit DestroyRef:

private destroyRef = inject(DestroyRef);
ngOnInit() {
  this.someStream$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...);
}

Replaces the older manual pattern:

// ❌ Pre-16 — verbose, easy to forget
private destroy$ = new Subject<void>();
ngOnInit() {
  this.something$.pipe(takeUntil(this.destroy$)).subscribe(...);
}
ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

firstValueFrom / lastValueFrom

For one-shot observables (HTTP), convert to Promise:

import { firstValueFrom } from 'rxjs';

async loadUser(id: string) {
  return await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
}

Replaces deprecated .toPromise().

Bridging signals ↔ RxJS

import { toSignal, toObservable } from '@angular/core/rxjs-interop';

// Observable → Signal
@Injectable({ providedIn: 'root' })
export class UsersService {
  private http = inject(HttpClient);
  users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
}

// Signal → Observable
const filter = signal('');
const filter$ = toObservable(filter);   // emits whenever filter() changes

filter$.pipe(
  debounceTime(300),
  switchMap((q) => this.search(q)),
).subscribe(...);

toSignal requires either initialValue OR requireSync: true (which throws if observable doesn't emit synchronously). Most HTTP cases need initialValue.

NgRx Store (when @ngrx/store detected)

pnpm add @ngrx/store @ngrx/effects @ngrx/store-devtools @ngrx/entity

Setup (standalone)

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideStore({ users: usersReducer }),
    provideEffects([UsersEffects]),
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
  ],
});

Actions

// users.actions.ts
import { createAction, createActionGroup, props, emptyProps } from '@ngrx/store';

export const UsersActions = createActionGroup({
  source: 'Users',
  events: {
    'Load Users': emptyProps(),
    'Load Users Success': props<{ users: User[] }>(),
    'Load Users Failure': props<{ error: string }>(),
    'Delete User': props<{ id: string }>(),
  },
});

// Dispatch
this.store.dispatch(UsersActions.loadUsers());
this.store.dispatch(UsersActions.deleteUser({ id: '123' }));

Reducer

// users.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { UsersActions } from './users.actions';

export interface UsersState {
  items: User[];
  loading: boolean;
  error: string | null;
}

const initialState: UsersState = { items: [], loading: false, error: null };

export const usersReducer = createReducer(
  initialState,
  on(UsersActions.loadUsers, (state) => ({ ...state, loading: true, error: null })),
  on(UsersActions.loadUsersSuccess, (state, { users }) => ({ ...state, loading: false, items: users })),
  on(UsersActions.loadUsersFailure, (state, { error }) => ({ ...state, loading: false, error })),
  on(UsersActions.deleteUser, (state, { id }) => ({ ...state, items: state.items.filter((u) => u.id !== id) })),
);

Selectors

// users.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import type { UsersState } from './users.reducer';

export const selectUsersState = createFeatureSelector<UsersState>('users');

export const selectAllUsers = createSelector(
  selectUsersState,
  (state) => state.items
);

export const selectUserCount = createSelector(
  selectAllUsers,
  (users) => users.length
);

export const selectUserById = (id: string) => createSelector(
  selectAllUsers,
  (users) => users.find((u) => u.id === id)
);

Effects

// users.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { UsersActions } from './users.actions';

@Injectable()
export class UsersEffects {
  private actions$ = inject(Actions);
  private http = inject(HttpClient);

  loadUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UsersActions.loadUsers),
      switchMap(() =>
        this.http.get<User[]>('/api/users').pipe(
          map((users) => UsersActions.loadUsersSuccess({ users })),
          catchError((err) => of(UsersActions.loadUsersFailure({ error: err.message })))
        )
      )
    )
  );
}

Component usage

@Component({...})
export class UsersListComponent implements OnInit {
  private store = inject(Store);

  users = this.store.selectSignal(selectAllUsers);   // signal-based selector (NgRx 17+)
  count = this.store.selectSignal(selectUserCount);

  ngOnInit() {
    this.store.dispatch(UsersActions.loadUsers());
  }
}

store.selectSignal(selector) returns a Signal<T> — read with users() in template.

For Observable-based: users$ = this.store.select(selectAllUsers) + async pipe.

NgRx Component Store (per-component reactive store)

For feature-local state too small for full Store pattern:

import { ComponentStore } from '@ngrx/component-store';

interface PaginationState {
  page: number;
  pageSize: number;
  total: number;
}

@Injectable()
export class PaginationStore extends ComponentStore<PaginationState> {
  constructor() {
    super({ page: 1, pageSize: 10, total: 0 });
  }

  readonly page$ = this.select((s) => s.page);
  readonly totalPages$ = this.select((s) => Math.ceil(s.total / s.pageSize));

  readonly setPage = this.updater((state, page: number) => ({ ...state, page }));

  readonly loadPage = this.effect((trigger$: Observable<void>) =>
    trigger$.pipe(
      switchMap(() => this.api.fetchPage(this.get().page).pipe(
        tapResponse(
          (data) => this.patchState({ total: data.total }),
          (err) => console.error(err)
        )
      ))
    )
  );
}

// In component
providers: [PaginationStore]

Provided per-component (not providedIn: 'root') — unique instance.

NgRx Signals (newer, 2024+)

@ngrx/signals provides a signal-based store API. Less verbose than full Store + Effects:

import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withState<{ items: User[]; loading: boolean }>({ items: [], loading: false }),
  withComputed((state) => ({
    count: computed(() => state.items().length),
  })),
  withMethods((state, http = inject(HttpClient)) => ({
    async loadUsers() {
      patchState(state, { loading: true });
      const items = await firstValueFrom(http.get<User[]>('/api/users'));
      patchState(state, { items, loading: false });
    },
  }))
);

// Use
const store = inject(UsersStore);
store.count();         // signal
store.loadUsers();     // method

Drop-in for many feature-state cases. Less boilerplate than full NgRx Store.

Anti-patterns

  • ❌ Subscribing to observables without unsubscription strategy — leaks memory.
  • ❌ Using effect() for derived state — use computed().
  • ❌ Reading this.someService.value() inside effect() then calling this.someService.update() from same effect — circular update warning.
  • ❌ Calling signal without parens (*ngIf="user" instead of *ngIf="user()") — common bug, doesn't unwrap.
  • ❌ Putting all state in NgRx Store when service+signals would do.
  • ❌ Subscribing to NgRx Store.select() then mutating result — selectors return immutable references.
  • ❌ Forgetting provideStore / provideEffects in standalone bootstrap — runtime errors.
  • ❌ Mixing observable + signal mental models in one feature without toSignal/toObservable bridges.
  • ❌ Using BehaviorSubject exposed publicly without .asObservable() — consumers can .next().
  • ❌ Manual Subject<void> cleanup pattern in Angular 16+ — use takeUntilDestroyed().

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

Angular 18-21 project structure, standalone components vs NgModule, control flow (@if/@for/@switch + *ngIf/*ngFor legacy), decorators, dependency injection (inject() function), lifecycle hooks, pipes, Angular Universal SSR pointer. Use this skill to: - Detect project style (standalone vs NgModule) and apply matching patterns. - Pick correct decorators and DI approach. - Use modern control flow (@if/@for/@switch) in Angular 17+ projects. - Apply `inject()` function over constructor injection where appropriate. - Wire bootstrap correctly (bootstrapApplication for standalone, AppModule for legacy). Do NOT use this skill for: - State management (see angular-state-and-rx). - Routing (see angular-routing). - Forms (see angular-forms). - Testing (see angular-testing).

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AratKruglik/claude-sdlc422026년 10월 17일 업데이트

Angular forms: Reactive Forms (preferred — typed FormGroup/FormControl since Angular 14, FormBuilder, custom + async validators, FormArray, multi-step) and Template-driven (`[(ngModel)]` + FormsModule). Validation strategies, server error mapping, accessibility. Use this skill to: - Build Reactive Forms with typed FormGroup/FormControl. - Use FormBuilder для concise syntax. - Implement custom synchronous and async validators. - Wire FormArray for dynamic field lists. - Map server errors back to form fields. - Pick Reactive vs Template-driven (prefer Reactive). Do NOT use this skill for: - General conventions (see angular-conventions). - State management beyond forms (see angular-state-and-rx). - Routing (see angular-routing). - Testing forms (see angular-testing).

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AratKruglik/claude-sdlc422026년 10월 17일 업데이트

Angular Router (built-in `@angular/router`) — route configuration for standalone and NgModule projects, functional guards (Angular 14.1+), lazy loading, route resolvers, typed params via signals/observables, programmatic navigation, route data and meta. Use this skill to: - Configure routes (standalone-style or NgModule-style). - Use functional guards (canActivate as function, preferred over class-based in 17+). - Lazy-load components or feature modules. - Implement auth guards via route meta + functional guards. - Read params/queries via `inject(ActivatedRoute)` + signals or RxJS. Do NOT use this skill for: - General conventions (see angular-conventions). - State management (see angular-state-and-rx). - Forms (see angular-forms). - Testing routes (see angular-testing).

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AratKruglik/claude-sdlc422026년 10월 17일 업데이트

Testing Angular 18-21: TestBed, component harnesses (@angular/cdk/testing), Karma+Jasmine (default historical) vs Jest (jest-preset-angular, modern), Angular Testing Library (RTL-style). HttpClient mocking via HttpTestingController. NgRx Effects testing. Cypress / Playwright e2e. Use this skill to: - Detect runner (Karma+Jasmine vs Jest) and configure correctly. - Write component tests with TestBed. - Use component harnesses for Material / custom UI components. - Mock HttpClient via provideHttpClientTesting + HttpTestingController. - Test signal-based inputs with componentRef.setInput(). - Test NgRx Effects with provideMockActions. Do NOT use this skill for: - General Angular conventions (see angular-conventions). - Routing patterns broadly (see angular-routing — covers testing routes briefly). - Form patterns broadly (see angular-forms).

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AratKruglik/claude-sdlc422026년 10월 17일 업데이트

Shared conventions for every SDLC development-phase architect agent: hard rules, code quality bar, workflow steps (superpowers invocation, spec reading, codebase exploration, verification), and the report/compact-summary contract. Architects load this skill first, then apply their stack-specific instructions on top.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AratKruglik/claude-sdlc422026년 10월 17일 업데이트

ASP.NET Core web framework conventions: Minimal API vs MVC controllers, Program.cs composition, DI lifetimes (Scoped/Singleton/Transient), Options pattern with IOptions<T>, middleware ordering (HTTPS redirect → routing → authentication → authorization), model binding and validation (FluentValidation / DataAnnotations), ProblemDetails error handling, structured logging with ILogger<T>, configuration layering (appsettings.json + environment variables + User Secrets), and health checks. Works alongside csharp-foundation:csharp-conventions and aspnet-core-plugin:efcore-patterns. Use this skill to: - Compose Program.cs correctly — register services, configure middleware in the right order, map endpoints. - Apply the Options pattern to avoid passing raw IConfiguration into services. - Write Minimal API endpoint groups with typed results and authorization. - Handle cross-cutting errors uniformly with ProblemDetails. - Configure structured logging and health checks for production readiness. Do NOT use this skill for: - EF Core entity configuration and migrations — see aspnet-core-plugin:efcore-patterns. - C# language idioms — see csharp-foundation:csharp-conventions. - Testing — see csharp-foundation:dotnet-testing.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AratKruglik/claude-sdlc422026년 10월 17일 업데이트

AratKruglik의 스킬 모두 보기

이 스킬의 문제 신고