Update Slice: Form state is not a DTO
Update Slice: Updating without turning the store into a god object
Section titled “Update Slice: Updating without turning the store into a god object”Changing data is a command.
Not because a PUT or PATCH is technically complicated.
But because an update changes an existing domain object.
A create says:
Create something new.
A delete says:
Remove something existing.
An update says:
Change something existing into a new state.
That sounds close to create.
And technically, the flow really is very similar.
But here too:
An update shouldn’t end as a quick form submit:
submit → http.put() → reload() → toast() → navigate()That’s short.
But the component knows too much then:
- it knows HTTP
- it knows the update operation
- it knows reload
- it knows notification
- it knows navigation
- it knows error handling
That’s why, in this article, I structure update as a command flow:
update intent → facade.updateArticle(command) → articleUpdateEvents.updateRequested(command) → ArticleCommandStore → ArticleCommand.updateArticle(command) → updateSucceeded | updateFailed → reload / notification / navigationThe submit doesn’t update directly.
The submit sends a change intent.
The boundary
Section titled “The boundary”
The most important point:
Update is not a form with http.put() behind it.
Update is an intent.
That intent can succeed.
It can fail.
And both outcomes can be followed by several independent reactions.
updateRequested → PUT → updateSucceeded | updateFailed → independent reactionsThat’s a different design than:
submit → http.put() → reloadList() → toast() → navigate()In the direct flow, every step knows too much about the next one.
In the event-driven slice, intent, execution, and reaction are separated.
What this article deliberately doesn’t show: form complexity
Section titled “What this article deliberately doesn’t show: form complexity”Update flows can get big quickly.
You could talk about a lot of things:
- initial values from the read model
- dirty state
- validation
- partial update
- full update
- optimistic update
- conflicts during concurrent editing
- ETags or version numbers
- save-on-blur
- auto-save
These are important topics.
But they aren’t the focus of this article.
Here, it’s only about the slice after the decision:
The user has submitted a change.
From this moment on, form state becomes a command.
Form submit → UpdateArticleCommand → updateRequestedThe form deliberately stays thin in this article.
No validation.
No dirty tracking.
No conflict handling.
Just the update flow.
Why not update in the Read Store?
Section titled “Why not update in the Read Store?”The temptation is big.
The Read Store already knows the article anyway.
So it quickly gets a method too:
updateArticle(command: UpdateArticleCommand): void { this.http.put(`/api/article/${command.articleId}`, command).subscribe(() => { this.reload(); });}That works.
But the store’s role changes.
It’s no longer just a read model.
It’s suddenly a command handler too.
It loads data.
It holds ViewModels.
It updates data.
It handles errors.
It triggers reload.
Maybe it shows toasts later, or navigates back.
And at some point, it’s still called ArticleStore, but it’s actually a small god.
That’s exactly what this slice wants to avoid.
ArticleResource → encapsulates a private httpResource → provides article, isLoading, error, and reload
Read Store → orchestrates source signals → computes the ViewModel → reacts to loadRequested
Command Store → listens to updateRequested → executes the PUT → fires updateSucceeded / updateFailed → forwards success reactions as eventsThe Read Store stays a Read Store.
The Command Store stays a Command Store.
Target structure
Section titled “Target structure”One possible structure for this slice:
article/├── infrastructure/│ ├── article.command.ts│ ├── article-update.dto.ts│ ├── article-update.mapper.ts│ ├── article.dto.ts│ ├── article.mapper.ts│ └── article.resource.ts├── +state/│ ├── article.store.ts│ ├── article.vm.ts│ ├── article-view-model.mapper.ts│ ├── update-article.command.ts│ ├── article-update.events.ts│ ├── article-read.events.ts│ └── article-command.store.ts├── application/│ └── article-update.facade.ts└── presentation/ ├── article-edit-page.component.ts └── article-edit-page.component.htmlIf create, update, and delete live in the same feature, they often share the same command infrastructure:
+state/├── create-article.command.ts├── update-article.command.ts├── delete-article.command.ts├── article-create.events.ts├── article-update.events.ts├── article-delete.events.ts└── article-command.store.ts
infrastructure/└── article.command.tsThe pattern stays the same:
ArticleCommandStore → uses ArticleCommandThe store decides when a write operation gets executed.
The infrastructure decides how this write gets translated against the external API.
On the read side, the same boundary applies as in the retrieve slice:
private httpResource → ArticleResource → safe source signals → ArticleStore → ViewModelThe concrete HttpResourceRef stays in the infrastructure.
The Read Store knows neither hasValue() nor the throwing behavior of value().
1. Command: describing the change intent
Section titled “1. Command: describing the change intent”An update command needs the ID of the existing article and the new values.
export interface UpdateArticleCommand { readonly articleId: string; readonly title: string; readonly subtitle: string; readonly content: string;}This is not a DTO.
This is not a ViewModel.
This is not a form model.
This is the domain intent:
UpdateArticleCommand = the user wants to change this articleThe command contains articleId, because update targets an existing object.
The DTO only emerges later, at the API boundary.
Not in the form.
Not in the facade.
Not in the Read Store.
2. Events: requested, succeeded, failed
Section titled “2. Events: requested, succeeded, failed”Now we define the events of the update slice.
import { eventGroup, type } from '@ngrx/signals/events';
import { UpdateArticleCommand } from './update-article.command';
export const articleUpdateEvents = eventGroup({ source: 'Article Update', events: { updateRequested: type<UpdateArticleCommand>(), updateSucceeded: type<{ readonly articleId: string }>(), updateFailed: type<{ readonly articleId: string; readonly error: unknown; }>(), },});The language matters more than the syntax again:
updateRequestedupdateSucceededupdateFailedupdateRequested isn’t a result yet.
It’s an intent.
updateSucceeded and updateFailed are results of execution.
I also include the articleId in updateFailed.
Why?
Because an error without context is worth little.
Especially with several editable articles, you want to know:
Which update failed?That makes error reactions more testable and traceable.
3. Read event: reload as its own intent
Section titled “3. Read event: reload as its own intent”After a successful update, the read data should be refreshed.
But the Command Store shouldn’t know the Read Store directly.
Not like this:
updateSucceeded → articleStore.reload()But like this:
updateSucceeded → articleReadEvents.loadRequested() → Read Store triggers ArticleResource.reload()There’s a read event for that:
import { eventGroup, type } from '@ngrx/signals/events';
export const articleReadEvents = eventGroup({ source: 'Article Read', events: { loadRequested: type<void>(), },});This looks like a small indirection.
But that indirection is exactly the boundary.
The write side doesn’t say how the read side loads.
It only says:
After this successful write, the read article data should be requested again.
4. Infrastructure: command to DTO, then PUT
Section titled “4. Infrastructure: command to DTO, then PUT”Up to the infrastructure, we work with the command.
The DTO only emerges at the API boundary.
article-update.dto.ts
Section titled “article-update.dto.ts”export interface UpdateArticleDto { readonly title: string; readonly subtitle: string; readonly content: string;}
export interface UpdateArticleResponseDto { readonly id: string;}The DTO describes the external contract.
Not the internal language.
article-update.mapper.ts
Section titled “article-update.mapper.ts”import { UpdateArticleCommand } from '../+state/update-article.command';import { UpdateArticleDto } from './article-update.dto';
export const toUpdateArticleDto = ({ title, subtitle, content }: UpdateArticleCommand): UpdateArticleDto => ({ title, subtitle, content,});The mapping is boring here.
That’s good.
It still shows the right place, though.
If the external API later needs different field names, extra wrappers, or technical metadata, that happens at this boundary.
Not in the component.
Not in the facade.
Not in the command.
article.command.ts
Section titled “article.command.ts”import { HttpClient } from '@angular/common/http';import { Injectable, inject } from '@angular/core';
import { UpdateArticleCommand } from '../+state/update-article.command';import { UpdateArticleResponseDto } from './article-update.dto';import { toUpdateArticleDto } from './article-update.mapper';
@Injectable()export class ArticleCommand { private readonly http = inject(HttpClient);
readonly updateArticle = (command: UpdateArticleCommand) => this.http.put<UpdateArticleResponseDto>(`https://lorem-api.com/api/article/${command.articleId}`, toUpdateArticleDto(command));}The name is deliberately ArticleCommand.
Not ArticleResource.
Not ArticleStore.
This class bundles write operations against the external API.
UpdateArticleCommand → domain intent
ArticleCommand → infrastructure operations for write accessIn this example, I use PUT, because the command passes the complete new article values.
If a feature deliberately only wants to change individual fields, a PATCH flow would be a separate design.
The important point stays the same:
UpdateArticleCommand → toUpdateArticleDto() → PUTThe DTO emerges at the API boundary.
5. Command Store: executing requested, firing the result
Section titled “5. Command Store: executing requested, firing the result”Now comes the Command Store.
It holds no ViewModel.
It renders nothing.
It listens to events, executes the infrastructure, and publishes result events.
import { inject } from '@angular/core';import { signalStore, withProps } from '@ngrx/signals';import { Events, withEventHandlers } from '@ngrx/signals/events';import { mapResponse } from '@ngrx/operators';import { exhaustMap, map } from 'rxjs';
import { ArticleCommand } from '../infrastructure/article.command';import { articleReadEvents } from './article-read.events';import { articleUpdateEvents } from './article-update.events';
export const ArticleCommandStore = signalStore( withProps(() => ({ _articleCommand: inject(ArticleCommand), })),
withEventHandlers(({ _articleCommand }, events = inject(Events)) => ({ updateArticle$: events.on(articleUpdateEvents.updateRequested).pipe( exhaustMap((command) => _articleCommand.updateArticle(command).pipe( mapResponse({ next: (response) => articleUpdateEvents.updateSucceeded({ articleId: response.id, }), error: (error: unknown) => articleUpdateEvents.updateFailed({ articleId: command.articleId, error, }), }), ), ), ),
reloadOnUpdateSucceeded$: events.on(articleUpdateEvents.updateSucceeded).pipe(map(() => articleReadEvents.loadRequested())),
// notifyOnUpdateSucceeded$: events // .on(articleUpdateEvents.updateSucceeded) // .pipe( // map(() => // notificationEvents.showSuccess({ // summary: { // key: 'articles.notifications.update.success.summary', // }, // detail: { // key: 'articles.notifications.update.success.detail', // }, // }), // ), // ),
// notifyOnUpdateFailed$: events // .on(articleUpdateEvents.updateFailed) // .pipe( // map(() => // notificationEvents.showError({ // summary: { // key: 'articles.notifications.update.error.summary', // }, // detail: { // key: 'articles.notifications.update.error.detail', // }, // }), // ), // ),
// navigateOnUpdateSucceeded$: events // .on(articleUpdateEvents.updateSucceeded) // .pipe( // map(() => articleNavigationIntentEvents.openList()), // ), })),);The store does three things:
1. accept updateRequested2. execute the PUT via infrastructure3. publish updateSucceeded or updateFailedAfter that, it translates updateSucceeded into a read event:
updateSucceeded → loadRequestedThis is deliberately not a direct call to the Read Store.
The Command Store doesn’t know the resource.
It doesn’t know the ViewModel.
It doesn’t know presentation.
It only knows events.
Why mapResponse?
Section titled “Why mapResponse?”The PUT call has two possible outcomes:
success → updateSucceeded
error → updateFailedmapResponse is exactly what makes this readable.
The success case produces a success event.
The error case produces an error event.
mapResponse({ next: (response) => articleUpdateEvents.updateSucceeded({ articleId: response.id, }), error: (error: unknown) => articleUpdateEvents.updateFailed({ articleId: command.articleId, error, }),});That keeps the Command Store declarative.
No nested subscribe.
No local error handling in the component.
No direct toast in the catch.
Just:
HTTP result → EventWhy exhaustMap?
Section titled “Why exhaustMap?”Update can get triggered twice as well.
The user clicks twice.
The form submits twice.
The network hangs.
That’s why I use exhaustMap for this article.
exhaustMap → ignores further updates while one is running
concatMap → queues updates one after another
mergeMap → allows parallel updates
switchMap → cancels earlier updatesFor commands, switchMap is often dangerous.
An update is not a search query.
Once an update is in flight, I usually don’t want to silently cancel it just because a second submit comes in.
exhaustMap is the conservative choice for this simple slice.
6. Success reactions stay events
Section titled “6. Success reactions stay events”After a successful update, we want to reload in this article.
Later, maybe also show a notification or navigate back to the list.
What matters is the direction:
updateSucceeded → articleReadEvents.loadRequested()
updateSucceeded → notificationEvents.showSuccess(...)
updateSucceeded → articleNavigationIntentEvents.openList()These are event reactions.
Not direct imperative side effects in the Command Store.
That’s why I use map here.
An event becomes a new event.
reloadOnUpdateSucceeded$: events .on(articleUpdateEvents.updateSucceeded) .pipe( map(() => articleReadEvents.loadRequested()), ),That’s more testable than:
tap(() => articleStore.reload());And it keeps the coupling small.
The Command Store doesn’t say:
Read Store, reload yourself.
It only says:
A load has been requested.
The read side decides for itself what that means.
Notifications can be modeled this way too:
notifyOnUpdateSucceeded$: events .on(articleUpdateEvents.updateSucceeded) .pipe( map(() => notificationEvents.showSuccess({ summary: { key: 'articles.notifications.update.success.summary', }, detail: { key: 'articles.notifications.update.success.detail', }, }), ), ),This article doesn’t fully implement notification and navigation.
It only sketches them.
The point is:
updateSucceeded → loadRequested → showSuccess → openListAs long as a reaction can be described as an event again, map stays the cleaner choice.
tap belongs at genuine imperative boundaries: router, toast service, logging, or external APIs.
7. Read Store: reacting to loadRequested
Section titled “7. Read Store: reacting to loadRequested”The concrete Angular resource stays completely in the infrastructure.
ArticleResource keeps the httpResource private and only exposes a small, named API publicly:
article: Signal<Article | null>isLoading: Signal<boolean>error: Signal<Error | undefined>reload(): voidThat keeps the technical guards in the right place too.
hasValue() protects access to value().
That’s not a ViewModel decision made by the Read Store.
It’s lifecycle semantics of the Angular resource and therefore belongs in the infrastructure adapter that owns this resource.
The Read Store afterward knows neither:
HttpResourceRefhasValue()- the throwing behavior of
value() ResourceStatus- parse or request details
It only orchestrates source signals, the ViewModel projection, and read intents.
import { computed, inject } from '@angular/core';import { signalStore, withComputed, withProps } from '@ngrx/signals';import { Events, withEventHandlers } from '@ngrx/signals/events';import { tap } from 'rxjs';
import { ArticleResource } from '../infrastructure/article.resource';import { articleReadEvents } from './article-read.events';import { toArticleViewModel } from './article-view-model.mapper';
export const ArticleStore = signalStore( withProps(() => ({ _articleResource: inject(ArticleResource), })),
withComputed(({ _articleResource }) => ({ isLoading: _articleResource.isLoading, error: _articleResource.error, vm: computed(() => toArticleViewModel(_articleResource.article())), })),
withEventHandlers(({ _articleResource }, events = inject(Events)) => ({ reloadOnLoadRequested$: events.on(articleReadEvents.loadRequested).pipe(tap(() => _articleResource.reload())), })),);The ViewModel mapper thereby maps:
Article | null → ArticleVm | nullThe domain entity itself stays strict.
There’s no artificial empty article just so the store can get by without a condition.
An existing Article must be valid.
The absence of a readable value gets normalized to null at the infrastructure boundary.
The store just projects this source further.
tap is fine here.
Not because the store itself executes HTTP or resource logic.
But because the event flow triggers an explicit operation of its infrastructure dependency at this exact point:
loadRequested → ArticleResource.reload()Before this, events get mapped onto events.
Here, the read operation actually gets triggered.
The store doesn’t interpret any technical resource lifecycle in doing so.
It only orchestrates:
article → ArticleVm
loadRequested → ArticleResource.reload()That keeps the read flow quiet.
Reading the store, you’re interested in:
- which source gets consumed
- which ViewModel emerges
- which intent triggers which operation
Not the usage instructions of httpResource.
8. Application: facade sends the intent
Section titled “8. Application: facade sends the intent”The update facade doesn’t execute an HTTP call.
It doesn’t know an HttpClient.
It doesn’t know the resource either.
It only publishes the intent.
import { Injectable } from '@angular/core';import { injectDispatch } from '@ngrx/signals/events';
import { UpdateArticleCommand } from '../+state/update-article.command';import { articleUpdateEvents } from '../+state/article-update.events';
@Injectable()export class ArticleUpdateFacade { private readonly dispatchUpdate = injectDispatch(articleUpdateEvents);
readonly updateArticle = (command: UpdateArticleCommand): void => { this.dispatchUpdate.updateRequested(command); };}That’s the public API of the edit UI:
updateArticle(command)The facade doesn’t update an article.
It publishes the intent to change an article.
Presentation → facade.updateArticle(command) → articleUpdateEvents.updateRequested(command)What happens after that no longer belongs to presentation.
9. Presentation: keeping the signal form thin
Section titled “9. Presentation: keeping the signal form thin”For input, I use Signal Forms.
But deliberately thin.
Signal Forms aren’t the architecture here.
They’re only the input layer.
Validation, dirty state, conflict detection, and submit status are deliberately left out.
article-edit-page.component.ts
Section titled “article-edit-page.component.ts”import { Component, inject, signal } from '@angular/core';import { form } from '@angular/forms/signals';
import { ArticleCommandStore } from '../+state/article-command.store';import { ArticleStore } from '../+state/article.store';import { ArticleUpdateFacade } from '../application/article-update.facade';import { ArticleCommand } from '../infrastructure/article.command';import { ArticleResource } from '../infrastructure/article.resource';
export interface ArticleEditModel { readonly articleId: string; readonly title: string; readonly subtitle: string; readonly content: string;}
const existingArticle: ArticleEditModel = { articleId: 'foo', title: 'Existing title', subtitle: 'Existing subtitle', content: 'Existing content',};
@Component({ selector: 'app-article-edit-page', templateUrl: './article-edit-page.component.html', providers: [ArticleResource, ArticleCommand, ArticleStore, ArticleCommandStore, ArticleUpdateFacade],})export class ArticleEditPageComponent { protected readonly facade = inject(ArticleUpdateFacade);
protected readonly model = signal<ArticleEditModel>(existingArticle);
protected readonly articleForm = form(this.model);}existingArticle deliberately stands in here for the already loaded article.
In a real application, this value would come from a detail retrieve slice, a resolver, a route input, or a store signal.
I’m not showing that here, because the article would otherwise immediately turn into a select-by-id or byId article.
Here, it’s about the update submit:
Form model → UpdateArticleCommand → updateRequestedarticle-edit-page.component.html
Section titled “article-edit-page.component.html”<form class="article-edit" (ngSubmit)=" facade.updateArticle({ articleId: articleId(), title: model().title, subtitle: model().subtitle, content: model().content, }) "> <label> Title <input [field]="articleForm.title" /> </label>
<label> Subtitle <input [field]="articleForm.subtitle" /> </label>
<label> Content <textarea [field]="articleForm.content"></textarea> </label>
<button type="submit">Save changes</button></form>The template is deliberately unspectacular.
No HTTP.
No store.
No event dispatch.
No success logic.
No reload logic.
No navigation.
Presentation collects form data and calls the facade.
Nothing more.
change form → ngSubmit → facade.updateArticle(command)The submit is not an orchestrator.
It only sends the change intent.
10. Providers and lifecycle
Section titled “10. Providers and lifecycle”For the article, the slice can be provided locally on the page or the route.
providers: [ArticleResource, ArticleCommand, ArticleStore, ArticleCommandStore, ArticleUpdateFacade];In a real application, I’d often attach these providers to the route instead.
For the article, though, the local variant is helpful because it keeps the slice visibly self-contained.
What matters is:
ArticleResourceArticleCommandArticleStoreArticleCommandStoreArticleUpdateFacadetogether form the scope of this update slice.
ArticleResource encapsulates the private httpResource here.
ArticleStore consumes its source signals and orchestrates the read flow.
The ArticleCommandStore is deliberately part of this scope here.
That’s only the scope decision, though.
In a real application, the Command Store also actually has to get instantiated once in this slice’s lifecycle, so its event handlers are active.
That’s a small amount of lifecycle plumbing of this approach.
Depending on the project, that can happen via the route, a deliberately injected slice service, or another lifecycle spot.
I deliberately don’t show a hidden constructor hack like inject(ArticleCommandStore) just for instantiation here.
That might make the code run, but it would obscure the dependency.
What matters more for the article:
Command Store exists in the slice scope → listens to updateRequested → fires updateSucceeded / updateFailedHow exactly this lifecycle gets wired up in the project is framework mechanics.
The architectural boundary remains unchanged.
11. Create, update, and delete share reactions
Section titled “11. Create, update, and delete share reactions”In a real feature, update rarely stands alone.
Create, update, and delete often share the same reactions.
For example:
createSucceededupdateSucceededdeleteSucceeded → loadRequestedThen the Command Store can handle these success events together:
reloadOnWriteSucceeded$: events .on( articleCreateEvents.createSucceeded, articleUpdateEvents.updateSucceeded, articleDeleteEvents.deleteSucceeded, ) .pipe( map(() => articleReadEvents.loadRequested()), ),That’s stronger than three individual reload calls in three components.
The reload doesn’t hang off the button.
It doesn’t hang off the concrete command either.
It is triggered by the successful write.
Write succeeded → request a readThat’s the abstraction that matters.
Navigation can be modeled together this way too:
navigateOnWriteSucceeded$: events .on( articleCreateEvents.createSucceeded, articleUpdateEvents.updateSucceeded, ) .pipe( map(() => articleNavigationIntentEvents.openList()), ),Delete doesn’t have to automatically fall into the same navigation.
That’s a domain decision.
The point is:
Reactions hang off the result.
Not off the button.
12. Error is also a result
Section titled “12. Error is also a result”Errors shouldn’t just end up as a local catchError in the component either.
An error is also a result of the command.
updateRequested → PUT → updateFailed({ articleId, error })What happens after that is, again, a reaction:
updateFailed → notificationEvents.showError(...)
updateFailed → // keep form open
updateFailed → // mark form as failedFor this article, the event is enough.
A real UI could later react to updateFailed and show a toast, keep the form open, or mark fields.
What matters:
The Command Store doesn’t decide how the error gets displayed.
It only publishes:
The update failed.
13. Why this isn’t overengineering
Section titled “13. Why this isn’t overengineering”For a single form, this design can look like more code.
That’s true.
A direct http.put() call is shorter.
But shorter isn’t automatically simpler.
A direct call is often only short because it hides coupling.
Form knows updateForm knows HTTPForm knows reloadForm knows toastForm knows navigationForm knows error handlingThe event-driven slice makes these transitions visible.
Intent → Command → Result → ReactionThat’s not necessary for every small form.
But it’s valuable as soon as an update has more than one local consequence.
For example:
- reload a list
- update a detail page
- navigate back to the list
- show a toast
- close a dialog
- invalidate a cache
- update several read models
- roll back optimistic state
- show conflict status
Then an event isn’t an academic detour.
It’s a decoupling.
Conclusion
Section titled “Conclusion”An update is not a quick HTTP call from the component.
An update is a command.
The UI shouldn’t orchestrate this intent itself.
It only sends:
updateArticle(command) → updateRequested(command)The Command Store executes the write.
The infrastructure talks to the API.
Success and error become visible as events.
Reload, notification, and navigation don’t hang off the submit.
They hang off the result.
updateRequested → ArticleCommand.updateArticle(command) → updateSucceeded | updateFailed
updateSucceeded → articleReadEvents.loadRequested() → notificationEvents.showSuccess(...) → articleNavigationIntentEvents.openList()
updateFailed → notificationEvents.showError(...)That way, the change intent stays explicit.
The infrastructure encapsulates its own technical resource.
The Read Store only orchestrates source signals, projections, and read intents.
The Command Store stays a Command Store.
And the component doesn’t become the place where the form, HTTP, reload, navigation, and toasts all stick together.