DEV Community

Cover image for Chapter 5 — Route Destructive Delete Through the Same Service-Owned Boundary
SDuX Vault
SDuX Vault

Posted on Originally published at sdux-vault.com

Chapter 5 — Route Destructive Delete Through the Same Service-Owned Boundary

Delete is the mutation most likely to tempt a developer into a one-off array splice inside a component. Chapter 5 shows that a destructive write does not need a different architecture than create or update: switch the Merge stage to identifier-based semantics, submit the target identity through the same service-owned FeatureCell method, and keep confirmation state local to the view.

A remove button is easy to wire up badly. It is one click away from filtering an array in the component and calling it done. That approach quietly moves collection mutation policy into the UI layer, exactly the coupling the create and update chapters worked to avoid. Chapter 5 keeps the same boundary in place for the operation that removes data instead of adding it.

Key takeaway: Deletion is still a merge. Register identifier-based merge semantics once, then submit the target identity through the service's existing write path with a delete flag. The component never touches the committed collection.

Why Delete Tempts Developers to Bypass the Pipeline

Create and update both produce a record the pipeline can merge back into the collection. Delete produces nothing — its whole purpose is to make a record disappear. That asymmetry is exactly why delete is the operation most often implemented as a manual .filter() against local component state: there is no obvious "new value" to hand to a write API, so the shortcut of rebuilding the array by hand feels natural.

The shortcut has a cost. A component that filters its own copy of a collection is now responsible for knowing the identity field, keeping that copy in sync with every other write path, and re-implementing removal semantics that the Merge stage already provides. Chapter 5 avoids all of that by treating delete as a merge request with a flag, not a different kind of state management.

Warning: A component-level .filter() against a local array copy is not synchronized with the FeatureCell's committed collection. Any other consumer of that Feature State will not see the removal, and the next unrelated write can silently reintroduce the deleted record.

Switching to Identifier-Based Merge Semantics

Before a service can update or remove a specific record, the registered FeatureCell needs to compare incoming records by identifier rather than simply appending them. Chapter 5 registers withArrayByIdMergeBehavior for the Merge stage — the same stage used by the create and update paths from earlier chapters, now configured to also honor a delete flag.

export const characterCell = FeatureCell<StarWarsCharacter[]>(
  {
    key: 'star-wars-character',
    initialState: STAR_WARS_CHARACTERS
  },
  [withArrayByIdMergeBehavior]
);

characterCell
  .withArrayMergeId({ idKey: 'id' })
  .initialize();
Enter fullscreen mode Exit fullscreen mode

With this behavior active, the Merge stage applies one consistent rule for every write: a matching identifier is updated, an unseen identifier is appended, and a merge request marked for deletion removes the matching record instead. The service does not need a separate removal algorithm — it needs a differently configured request to the same write path.

Adding a Service-Owned removeCharacter Method

The delete method looks almost identical to create and update: it still calls mergeState on the service-owned FeatureCell. The difference is the shape of the input and a second argument that marks the request as destructive.

function removeCharacter(id: number): void {
  characterCell.mergeState(
    {
      value: [{ id } as StarWarsCharacter]
    },
    { isDelete: true }
  );
}
Enter fullscreen mode Exit fullscreen mode

The incoming value only needs to carry the identifier the merge behavior matches on — it does not need the rest of the record. The second argument, { isDelete: true }, tells the active Array By ID Merge behavior to remove the matching record from the collection rather than update or append it.

Operation mergeState value Merge behavior result
Create New record, unseen identifier Appended to the collection
Update Full record, known identifier Matching record replaced
Delete Identifier only, plus isDelete: true Matching record removed

Design rule: Every write — create, update, or delete — is still a mergeState call on the service-owned FeatureCell. The service never needs a parallel, hand-built removal algorithm.

Staging a Cancelable Delete Confirmation Locally

Removing a record is harder to undo than editing one, so Chapter 5 adds a confirmation step before the service is called at all. That confirmation state — which record is pending removal, whether the user has confirmed it — belongs to the component, not the shared collection. Nothing about "is this delete currently being confirmed" is meaningful to any other consumer of the Feature State. The same pattern applies with local component state in any framework (useState, a ref, or a writable store).

protected readonly deleteCandidate = signal<StarWarsCharacter | null>(null);

protected requestDelete(): void {
  const character = this.selectedCharacter();

  if (character) {
    this.deleteCandidate.set(character);
    this.feedback.set(null);
  }
}
Enter fullscreen mode Exit fullscreen mode

A cancel handler clears deleteCandidate without calling the service at all — cancellation is purely a local state reset, the same principle the create and update chapters established for aborted edits. Only a confirm action calls removeCharacter, and only after the user has explicitly acknowledged the pending record.

Ownership test: If canceling the action should leave the committed collection untouched, the state describing that in-progress action belongs in the component. Only a confirmed, committed intent should reach the FeatureCell.

Handling Unknown or Stale IDs Safely

A confirmation panel reduces accidental deletes, but it does not guarantee the identity is still valid by the time the user confirms — another write could have already removed or replaced that record. Array By ID Merge handles this without extra service logic: when the submitted identifier has no match, the merge behavior leaves the visible collection state equivalent, rather than throwing or silently corrupting unrelated records.

Current collection Submitted identity Result
[{ id: 1 }, { id: 2 }] { id: 1 } Record 1 removed; record 2 preserved
[{ id: 1 }, { id: 2 }] { id: 99 } No match — collection remains unchanged

Because the removal rule is identity-based, one matching record is removed while every other record in the collection is preserved exactly as committed. The service does not need to special-case a missing identity — the configured merge behavior already defines what happens.

Verifying Delete Without Breaking the Boundary

A short set of questions confirms the boundary held. Does the service still own the only call that mutates committed Feature State? Is the pending-delete confirmation local to the component? Does canceling leave the collection untouched? Does confirming submit an identity through the same mergeState path used by create and update, just with isDelete: true?

Tests can confirm the service behavior directly by acting on the FeatureCell, settling the pipeline, and asserting on the resulting State — the same act, settle, assert pattern used throughout the tutorial series.

it('should remove the matching character from the current collection', async () => {
  const service = await configureService();

  service.removeCharacter(10);

  await vaultSettled(key);

  expect(service.state.value()).toEqual([initialCharacters[1]!]);
});

it('should safely remove against an empty collection when no value exists', async () => {
  const service = await configureService(null);

  service.removeCharacter(10);

  await vaultSettled(key);

  expect(service.state.value()).toBeUndefined();
});
Enter fullscreen mode Exit fullscreen mode

StackBlitz placeholder

The Chapter 5 source contains a StackBlitz placeholder, but no verified project URL is present yet. Add the verified project link here when the demo is published.

Review the boundary: If a component needs to know how identifiers are matched, which records survive a removal, or how a stale identity is handled, that knowledge belongs in the feature service and its configured merge behavior — not in the delete button's click handler.

Deeper Dive

Continue with the Chapter 5 delete tutorial, then compare it with the Chapter 4 add and edit boundary. Together they show that create, update, and delete are the same service-owned write path, configured with different merge semantics rather than three separate architectures.

Top comments (0)