VOOZH about

URL: https://dev.to/bock92/i-rewrote-angular-component-store-with-signals-and-cut-the-complexity-in-half-32hk

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


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