Skip to content

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 / navigation

The submit doesn’t update directly.

The submit sends a change intent.


The submit doesn't save directly. The submit sends a change intent.

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 reactions

That’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
→ updateRequested

The form deliberately stays thin in this article.

No validation.

No dirty tracking.

No conflict handling.

Just the update flow.


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 events

The Read Store stays a Read Store.

The Command Store stays a Command Store.


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.html

If 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.ts

The pattern stays the same:

ArticleCommandStore
→ uses ArticleCommand

The 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
→ ViewModel

The concrete HttpResourceRef stays in the infrastructure.

The Read Store knows neither hasValue() nor the throwing behavior of value().


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 article

The 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.


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:

updateRequested
updateSucceeded
updateFailed

updateRequested 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.


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.

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.

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.

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 access

In 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()
→ PUT

The 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 updateRequested
2. execute the PUT via infrastructure
3. publish updateSucceeded or updateFailed

After that, it translates updateSucceeded into a read event:

updateSucceeded
→ loadRequested

This 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.


The PUT call has two possible outcomes:

success
→ updateSucceeded
error
→ updateFailed

mapResponse 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
→ Event

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 updates

For 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.


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
→ openList

As 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.


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(): void

That 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:

  • HttpResourceRef
  • hasValue()
  • 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 | null

The 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.


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.

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
→ updateRequested
<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.


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:

ArticleResource
ArticleCommand
ArticleStore
ArticleCommandStore
ArticleUpdateFacade

together 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 / updateFailed

How 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:

createSucceeded
updateSucceeded
deleteSucceeded
→ loadRequested

Then 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 read

That’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.


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 failed

For 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.


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 update
Form knows HTTP
Form knows reload
Form knows toast
Form knows navigation
Form knows error handling

The event-driven slice makes these transitions visible.

Intent
→ Command
→ Result
→ Reaction

That’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.


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.