Why doesn't Angular signals run my custom 'equal' check when using mutate()?

674 views Asked by At

I have a signal in Angular that represents a hypothetical dashboard. Here is an oversimplification:

type DashboardStats = { 
  activeUsers: number,
  orderCount: number,
  revenue: number,
  ...
}

// using equality comparison from lodash
const dashboard = signal<DashboardStats>(initialState, { equal: isEqual } );   

Let's say I poll my 'orders' service every minute and need to update updatedOrderCount into the dashboard.

I've got two ways to change the signal, using either update() or mutate().

dashboard.update(currentValue => 
{
   return {
     ...currentValue,
     orderCount: updatedOrderCount
   }
})

After running my provided updateFn, the isEqual method from lodash will run and does a deep comparison of all the fields. If we haven't had any new orders in the past minute than the signal is unchanged and won't notify any consumers (such as computed signals or component template).

If I had used mutate() this is how it would look:

dashboard.mutate(dashboard => 
{
   dashboard.orderCount = updatedOrderCount;
})

First of all that looks a LOT more readable. I know which I'd want to write.

However as explained in the docs:

For writable signals, .mutate() does not check for equality because it mutates the current value without producing a new reference.

This has the unfortunate side effect that every single time I run this mutate call, the signal value will change and will cause the UI to update or computed signals that use it to recalculate.

Now this clearly isn't a performance issue for this simple hypothetical dashboard, but can cause a lot of churn for more complicated signals or a complex chain of computed signals. It can also make debugging harder.

Basically Angular is saying mutate means you know you're making a change.

So the question is why can't Angular add something like a boolean to force the check to run:

mutate(mutatorFn: (value: T) => void, runEqual?: boolean): void;

Allow me to force the equal function to run after a mutate and get a nicer developer experience.

1

There are 1 answers

0
Simon_Weaver On

The answer is actually quite simple!

Here's the current source for mutate for reference:

/**
* Calls `mutator` on the current value and assumes that it has been mutated.
*/
mutate(mutator: (value: T) => void): void {
 if (!this.producerUpdatesAllowed) {
   throwInvalidWriteToSignalError();
 }
 // Mutate bypasses equality checks as it's by definition changing the value.
 mutator(this.value);
 this.valueVersion++;
 this.producerMayHaveChanged();

 postSignalSetFn?.(); 
}

The issue is that to run any equal function (in any language) you need two inputs, a and b. The problem here is that the previous value has already gone forever since we updated the object in place.

So a === b is always true and therefore it's not possible to run equal on a before and after value (mutate means change the existing object).

The only way to run an equals with pure Javascript would be to serialize it first and again afterwards and that would be a terrible idea for performance sake.


One way to fix this is to only run the mutate call if we know 100% we have a change to make. While determining that should always be possible, it may not be practical or as simple as this:

if (updatedOrderCount != dashboard().orderCount)
{
   dashboard.mutate(dashboard => 
   {
      dashboard.orderCount = updatedOrderCount;
   });
}

The other approach is to use something like the immer package and specifically the produce function. Here's a helper function we could write:

export const mutateSignal = <T>(value: WritableSignal<T>, mutatorFn: (value: Draft<T>) => void) =>
{
    // use immer to create new state
    const newState = produce(value(), (draftState) => {
        mutatorFn(draftState);
    });

    // note: immer returns the original object instance if there were no changes
    const hasChanged = value() !== newState;

    if (hasChanged)
    {
        // update the signal value if we have new state
        value.set(newState);
    }

    return hasChanged;
}

Immer is an amazing project and allows you to detect whether or not the arbitrary code you ran change anything. The following won't result in a change to the signal if orderCount didn't change.

mutateSignal(dashboard, dashboard => 
{
   dashboard.orderCount = updatedOrderCount;
});

There's a few catches though:

  • Immer returns immutable (frozen) state. You therefore can't change the object later using Angular's signal.mutate() without getting an error. But that's a good side effect - you just have to be consistent which you use.
  • When running set on a signal (in general) if the value is an object the signal will ALWAYS be marked as changed. That may be surprising (especially if you're accustomed to using distinctUntilChanged(), but it's explained in defaultEquals. Therefore you MUST use an equal function when you define the signal if you're using this mutateSignal immer function.
  • You can use (a, b) => a === b for your equality function, but that only works if you only ever update the signal with immutable data.

I do want to stress that maybe you don't need to worry about this at all. Signals, and computed calculations are meant to be simple. But you may have only found this question because you were getting an unmanageable number of signal changes that weren't really changes. So hopefully this helps give you options :-)