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
Top comments (0)