Technology Apr 17, 2026 · 4 min read

I Rewrote Angular Component Store with Signals - And Cut the Complexity in Half

After 8+ years working with Angular, I thought I had state management figured out. Than I rewrote one of my real-world stores using Signals, because I realize I was mostly managing complexity, not reducing it. The Context Like many Angular developers, I've used NgRx for years. When Compo...

DE
DEV Community
by Simone Boccato
I Rewrote Angular Component Store with Signals - And Cut the Complexity in Half

After 8+ years working with Angular, I thought I had state management figured out.
Than I rewrote one of my real-world stores using Signals, because I realize I was mostly managing complexity, not reducing it.

The Context

Like many Angular developers, I've used NgRx for years. When ComponentStore came out, it felt like the perfect balance:

  • Local state
  • Reactive patterns
  • Powerfull async handling

In this case, I was working on a fairly standard feature:

  • Load user data
  • Manage countries, provinces, cities
  • Handle cascading selections

Nothing fancy - but not trivial either.

At some point, I stopped and asked myself:

Am I solving complexity... or just managing it better ?

So I tried something simple: rewrite the same store using Signal Store (Angular 19+).

Component Store version

Following the standard pattern, we define:

  • Selectors
  • Updaters
  • Effects

Selectors

readonly cities$: Observable<Record<SectionGeographicalType, City[]>> = this.select(
  (state: PersonalDataState) => state.cities,
);

readonly personalDataInfo$: Observable<PersonalDataInfo | null> = this.select(
  (state) => state.personalDataInfo
);

Updaters

readonly setCityList = this.updater((state: PersonalDataState, updateCities: UpdateCities) => {
  return {
    ...state,
    cities: {
      ...state.cities,
      [updateCities.type]: updateCities.cities,
    },
    error: null,
  };
});

Effects

readonly selectedCountry = this.effect((selectCountry$: Observable<SelectCountryModel>) => {
  return selectCountry$.pipe(
    switchMap((selectCountry: SelectCountryModel) => {
      return forkJoin([
        this.countryService.getProvinceList(selectCountry.country.sk),
        of(selectCountry.type),
      ]);
    }),
    map(([response, type]) => {
      this.setCityList({ type, cities: [] });

      if (!response?.success) {
        this.setProvinceList({ type, provinces: [] });
        return EMPTY;
      }

      this.setProvinceList({ type, provinces: response.data ?? [] });
      return EMPTY;
    }),
    catchError((error: any) => {
      return selectCountry$.pipe(
        tap((selectCountry: SelectCountryModel) => {
          this.setCityList({ type: selectCountry.type, cities: [] });
          this.setProvinceList({ type: selectCountry.type, provinces: [] });
        }),
        map(() => EMPTY),
      );
    }),
  );
});

What's the Problem ?

Nothing. This is correct, scalable and idiomatic RxJS.
But here's the issue:

It's harder to read than it needs to be for this level of complexity

To understand the flow, you need to:

  • Mentally simulate streams
  • Jump between effects, updaters and selector
  • Track async behavior across operators

That's a cognitive cost.

Rewrite to Signal Store

export const PersonalDataStore = signalStore(
  { providedIn: 'root' },
  withState(initialPersonalDataState),
  withMethods(
    (
      store,
      countryService: CountriesService = inject(CountriesService),
      logger: LoggerService = inject(LoggerService),
      personalDataService: PersonalDataService = inject(PersonalDataService),
      serviceHttpService: ServiceHttpService = inject(ServiceHttpService),
      userHttpService: UserHttpService = inject(UserHttpService),
    ) => {
      const _updateCityList = (updateCities: UpdateCities) => {
        patchState(store, {
          cities: {
            ...store._cities(),
            [updateCities.type]: updateCities.cities,
          },
        });
      };

      const _updateProvinceList = (updateProvinces: UpdateProvinces) => {
        patchState(store, {
          provinces: {
            ...store._provinces(),
            [updateProvinces.type]: updateProvinces.provinces,
          },
        });
      };

      const _updateUser = (user: PersonalDataInfo | null) => {
        patchState(store, { personalDataInfo: user });
      };

      const loadInitialData = async (): Promise<void> => {
        const user: Response<User> = await lastValueFrom(userHttpService.getUser());
        const personalDataInfo: PersonalDataInfo | null = personalDataService.convertUserToFormModel(user.data);
        _updateUser(user.success ? personalDataInfo : null);
        const countries = await lastValueFrom(countryService.countries$);
        patchState(store, { countries: countries });
      };

      const selectedCountry = async (selectCountry: SelectCountryModel): Promise<void> => {
        try {
          _updateCityList({ type: selectCountry.type, cities: [] });
          const response = await lastValueFrom(serviceHttpService.getProvinceList(selectCountry.country.sk));

          if (!response?.success) {
            _updateProvinceList({ type: selectCountry.type, provinces: [] });
          } else {
            _updateProvinceList({ type: selectCountry.type, provinces: response.data ?? [] });
          }
        } catch (e) {
          _updateCityList({ type: selectCountry.type, cities: [] });
          _updateProvinceList({ type: selectCountry.type, provinces: [] });
        }
      };

      const selectedProvince = async (selectProvince: SelectProvinceModel) => {
        try {
          const response = await lastValueFrom(
            serviceHttpService.getCityList(selectProvince.province.countrySk, selectProvince.province.code),
          );
          if (!response?.success) {
            _updateCityList({ type: selectProvince.type, cities: [] });
          } else {
            _updateCityList({ type: selectProvince.type, cities: response.data ?? [] });
          }
        } catch (e) {
          _updateCityList({ type: selectProvince.type, cities: [] });
        }
      };

      return {
        loadInitialData,
        selectedCountry,
        selectedProvince,
      };
    },
  ),
);

export type PersonalDataStore = InstanceType<typeof PersonalDataStore>;

The Real Difference

This isn't about syntax. it's about how your brain process the code.
There are:

  • No streams to simulate
  • No operators no mentally execute
  • No indirection between layers

Just:

  • perform an action
  • update the state

The Trade-Off

This rewrite is not "free".
I intentionally moved from reactive streams to imperative async flows.

What I lost:

  • Built-in cancellation (e.g switchMap)
  • Stream composition
  • Reactive coordination across multiple sources

What I gained:

  • Linear, readable logic
  • Easier onboarding
  • Lower cognitive overhead

And for this feature, that trade-off was worth it.

When ComponentStore Still Wins

There are cases where RxJS is absolutely the right tool:

  • Complex async orchestration
  • Race conditions and cancellation
  • WebSocket or event streams
  • Combining multiple reactive sources

In those scenarios, Signals won't replace RxJS - they complement it.

Final Thought

We didn't remove reactivity.
We just chose a simpler model for a problem that didn't need the full power of RxJS, and in doing that, we reduce the cognitive load without sacrificing the outcome

DE
Source

This article was originally published by DEV Community and written by Simone Boccato.

Read original article on DEV Community
Back to Discover

Reading List