DEV Community

Simone Boccato
Simone Boccato

Posted on

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
);
Enter fullscreen mode Exit fullscreen mode

Updaters

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

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),
      );
    }),
  );
});
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)