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
This article was originally published by DEV Community and written by Simone Boccato.
Read original article on DEV Community