Fetching latest headlines…
I Rewrote Angular Component Store with Signals - And Cut the Complexity in Half
NORTH AMERICA
🇺🇸 United StatesApril 17, 2026

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

0 views0 likes0 comments
Originally published byDev.to

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

Comments (0)

Sign in to join the discussion

Be the first to comment!