Editing NgRx Signal Store State with Angular Signal Forms

Editing NgRx Signal Store State with Angular Signal Forms

frontend Published 6 min read

Originally published on dev.to.

In the previous post we moved the create form of our task board to Angular Signal Forms and made it submit into an event-driven NgRx Signal Store through a mutation. The board shows household tasks in 3 columns, To Do, In Progress and Done, served by a mock service. It has a second form, though: the one that edits a task in place, and that one turned out to be a different problem.

A new task starts from nothing, so a local signal is the right model for it. A task we edit already lives in the store, and the store is the one owner of its state. In this post we look at who should own the draft while the user is typing, and why I let the store keep it.

Handing Store State to form()

The store keeps the task being edited in taskEdit, which is null while no edit is open, so the obvious first try is to hand that straight to form():

form(this.store.taskEdit);
// ❌ Argument of type 'DeepSignal<TaskEdit> | Signal<null>' is not assignable
//    to parameter of type 'WritableSignal<null>'.

form() needs a writable signal, since typing in a field writes into the model. A store's state is read-only from outside, and that is deliberate: only the store changes its state. So we need something writable that sits between the form and the store.

Two Ways to Own the Draft

The writable piece in between is linkedSignal. It works like a computed: its value comes from another signal and follows it whenever that signal changes. Unlike a computed, it is also writable, and a value we set holds until the source changes again. So it starts from the store and still lets the form type into it, and there are 2 ways to set it up.

In ngrx/platform#5053, Rainer Hahnekamp describes both: a local copy that is written back on submit, and, since Angular 22.1, a linkedSignal that writes straight to the store. Our board uses the second, but the local copy is the simpler one, so we start there.

A Local Copy, Written Back on Submit

Here the form works on its own copy, and the store only hears about it on save. Since our board doesn't do this, the following snippet isn't in its repo. It only shows the shape:

readonly edit = linkedSignal(() => this.store.taskEdit() ?? NO_EDIT);

readonly editForm = form(this.edit, path => {
  apply(path.title, taskTitleSchema);
});

The form types into the copy freely, and the copy starts again from the store whenever taskEdit changes. Submit then calls a store method with the copy.

NO_EDIT is an empty edit that stands in while taskEdit is null. The board only shows the editor while an edit is open, so it's there for the types, not for the screen. taskTitleSchema holds the title rules from the previous post, shared with the create form.

That's all a local copy needs, and it's enough as long as nothing else needs the draft while the user is typing.

The Store Owns the Draft

When something else does need the draft while the user types, the store has to hold it. linkedSignal has a set option for that, which decides what a write to it does. Our edit form uses it to send every write to the store instead of keeping a copy:

readonly edit = linkedSignal(() => this.store.taskEdit() ?? NO_EDIT, {
  set: edit => this.store.updateTaskEdit(edit),
});

readonly editForm = form(this.edit, path => {
  apply(path.title, taskTitleSchema);
});

Every write the form makes, each keystroke and every reset(value), goes through set to a store method instead of into a local copy. The store updates taskEdit, the linkedSignal recomputes from it, and the form shows what the store holds.

💡 The store owns the draft, and the form is a view over it, which is the same idea as in the create form, except that the store holds the draft instead of a local signal in the component.

Methods or Events for a Draft

Since the store now owns the draft, it also needs a way to open, change and close it. A card's Edit button opens it with startEditing, every write from the form goes through set to updateTaskEdit, and the editor's Cancel button closes it with stopEditing. So the store side is 3 small methods:

withMethods(store => ({
  startEditing(taskId: string) {
    const task = store.taskEntityMap()[taskId];
    if (task) {
      patchState(store, {
        taskEdit: { id: task.id, title: task.title, description: task.description ?? '' },
      });
    }
  },
  updateTaskEdit(taskEdit: TaskEdit) {
    patchState(store, { taskEdit });
  },
  stopEditing() {
    patchState(store, { taskEdit: null });
  },
})),

These are methods, not events, on purpose. Our store is event-driven, but updateTaskEdit fires on every keystroke and changes nothing but the draft.

An event per keystroke would add an entry to the event log for every key the user presses, so the events the rest of the board relies on would get lost among them.

startEditing and stopEditing could have been events, since the rule from the previous post is that anything nothing awaits can be an event. They stayed methods so the 3 live together as one small API for the draft, and because opening an editor is UI state, not a change to a task.

If your event log is also your audit trail, make them events.

Saving Without Overwriting a Status Move

Once the user is done typing, saving goes through a mutation from @ngrx-toolkit/core, with the same shape as the create form's, so the component can await the outcome:

saveTaskEdit: rxMutation<TaskEdit, Task>({
  operation: ({ id, title, description }: TaskEdit) =>
    store._taskService.updateTask(id, { title, description }),
  onSuccess: task =>
    store._dispatcher.dispatch(taskApiEvents.taskUpdatedSuccess(task)),
  onError: error =>
    store._dispatcher.dispatch(
      taskApiEvents.taskUpdatedFailure(errorMessage(error))
    ),
}),

The component awaits it inside submit(), and when the server rejects a save, its message shows under the title input, exactly like a rejected create in the previous post. The success event is where the edit ends, shown here without its logging:

on(taskApiEvents.taskUpdatedSuccess, event => [
  updateEntity(
    {
      id: event.payload.id,
      changes: { title: event.payload.title, description: event.payload.description },
    },
    { collection: 'task' }
  ),
  { taskEdit: null },
]),

And here is what that reducer does:

  1. It applies only the fields the form edits. Taking the whole saved task would also overwrite a status move that is still on its way to the server, since moving a card is an optimistic update.
  2. Setting taskEdit to null closes the editor, and the board removes the edit component.

Tests, One in Each Direction

The edit form gets the same test as the create form for a save the server rejects. These 2 tests cover the linkedSignal:

it('writes what is typed straight into the store', () => {
  component.editForm.title().value.set('Water the herbs');

  expect(store.taskEdit()?.title).toBe('Water the herbs');
});

it('follows the store when the draft changes there', () => {
  store.updateTaskEdit({ id: '1', title: 'Changed elsewhere', description: '' });

  expect(component.editForm.title().value()).toBe('Changed elsewhere');
});

The first checks that the form writes into the store, the second that the form follows the store, so together they make sure there is only one draft and it lives in the store.

Without the set option, the form falls back to a local copy and the first test fails, which is exactly the difference between the 2 approaches.

When to Let the Store Own the Draft

Which way a form should go depends on who else needs the draft.

Keep a local copy when:

  • Only the form needs the draft, and the store only cares about the saved result
  • You want cancelling to be simple: drop the copy, and the store never sees the edits

Let the store own it when:

  • Something else reacts to the draft while the user types, like a preview, a dirty flag on the board or autosave
  • The draft has to survive the form component being destroyed, for example on a route change
  • You already keep editor state such as taskEdit in the store

💡 Both are a few lines with linkedSignal, so this is a per-form choice. The set option is what turns a local copy into a store-owned draft, and the test that writes into the store is what keeps it that way.

Resources

Where does the draft live in your forms? If you've kept edit state in a SignalStore some other way, I'd like to hear how.