RxJS distinctUntilChanged - object comparison

28,798

Solution 1

I had the same problem, and fixed it with using JSON.stringify to compare the objects:

.distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))

This code will work if the attriutes are in the same order, if not it may break, so here's a quick fix for that (be careful this method is slower)

.distinctUntilChanged((a, b) => JSON.stringify(a).split('').sort().join('') === JSON.stringify(b).split('').sort().join(''))

Solution 2

When you have lodash in your application anyway, you can simply utilize lodash's isEqual() function, which does a deep comparison and perfectly matches the signature of distinctUntilChanged():

.distinctUntilChanged(isEqual),

Or if you have _ available (which is not recommended anymore these days):

.distinctUntilChanged(_.isEqual),

Solution 3

I finally figure out where problem is. Problem was in version of RxJS, in V4 and earlier is different parameters order than V5.

RxJS 4:

distinctUntilChanged = function (keyFn, comparer)

RxJS 5:

distinctUntilChanged = function (comparer, keyFn)

In every docs today, you can find V4 parameters order, beware of that!

Solution 4

You can also wrap the original distinctUntilChanged function.

function distinctUntilChangedObj<T>() {
  return distinctUntilChanged<T>((a, b) => JSON.stringify(a) === JSON.stringify(b));
}

This lets you use it just like the original.

$myObservable.pipe(
  distinctUntilChangedObj()
)

Cavets

This method also has several pitfalls as some commenters have pointed out.

  1. This will fail if the objects fields are ordered differently;
JSON.stringify({a:1, b:2}) === JSON.stringify({b:2, a:1})
// will return false :(
  1. This JSON.stringify will also throw an error if the object has circular references. For example stringify'ing something from the firebase sdk will lead you to these errors:
TypeError: Converting circular structure to JSON

Robust Solution

Use the deep-object-diff library instead of JSON.stringify. This will solve the above problems 👍

import { detailedDiff } from 'deep-object-diff';

function isSame(a, b) {
  const result = detailedDiff(a, b); 
  const areSame = Object.values(result)
    .every((obj) => Object.keys(obj).length === 0);
  return areSame;
}

function distinctUntilChangedObj<T>() {
  return distinctUntilChanged<T>((a, b) => isSame(a, b));
}

Solution 5

From RxJS v6+ there is distinctUntilKeyChanged

https://www.learnrxjs.io/operators/filtering/distinctuntilkeychanged.html

const source$ = from([
  { name: 'Brian' },
  { name: 'Joe' },
  { name: 'Joe' },
  { name: 'Sue' }
]);

source$
  // custom compare based on name property
  .pipe(distinctUntilKeyChanged('name'))
  // output: { name: 'Brian }, { name: 'Joe' }, { name: 'Sue' }
  .subscribe(console.log);
Share:
28,798
Daniel Suchý
Author by

Daniel Suchý

Updated on July 09, 2022

Comments

  • Daniel Suchý
    Daniel Suchý almost 2 years

    I have a stream of objects and I need to compare if the current object is not the same as the previous and in this case emit a new value. I found distinctUntilChanged operator should do exactly what I want, but for some reason, it never emits value except the first one. If I remove distinctUntilChanged values are emitted normally.

    My code:

    export class SettingsPage {
        static get parameters() {
            return [[NavController], [UserProvider]];
        }
    
        constructor(nav, user) {
            this.nav = nav;
            this._user = user;
    
            this.typeChangeStream = new Subject();
            this.notifications = {};
        }
    
        ngOnInit() {
    
            this.typeChangeStream
                .map(x => {console.log('value on way to distinct', x); return x;})
                .distinctUntilChanged(x => JSON.stringify(x))
                .subscribe(settings => {
                    console.log('typeChangeStream', settings);
                    this._user.setNotificationSettings(settings);
                });
        }
    
        toggleType() {
            this.typeChangeStream.next({
                "sound": true,
                "vibrate": false,
                "badge": false,
                "types": {
                    "newDeals": true,
                    "nearDeals": true,
                    "tematicDeals": false,
                    "infoWarnings": false,
                    "expireDeals": true
                }
            });
        }
    
        emitDifferent() {
            this.typeChangeStream.next({
                "sound": false,
                "vibrate": false,
                "badge": false,
                "types": {
                    "newDeals": false,
                    "nearDeals": false,
                    "tematicDeals": false,
                    "infoWarnings": false,
                    "expireDeals": false
                }
            });
        }
    }
    
  • Always_a_learner
    Always_a_learner almost 6 years
    How did you fix this? What did you change? If you can tell this it would be great.
  • user3701188
    user3701188 over 5 years
    I am filling in my input my unit cost for a product, with the same logic, but now I can only type 1 number at a time , unless i go back and click on the field. I would like to be able to type in multiple digits at a time which is the normal default. How can I do this
  • Mehdi Benmoha
    Mehdi Benmoha over 5 years
    Have you tried the throttling or debouncing rxJS operators ?
  • user3701188
    user3701188 over 5 years
    that was the 1st thing I thought of, to use debounce but what I realised was when I type in the 1st digit the input loses focus then after the time span the values are emitted so it doesnt allow me to finish typing
  • cobolstinks
    cobolstinks over 5 years
    thanks this save my butt, angular's valuesChanged keeps emitting values even though nothing was changed. This object comparison put an end to that for me.
  • Milad
    Milad over 5 years
    this will give you incorrect true when you delete a property and add it back.
  • Mehdi Benmoha
    Mehdi Benmoha over 5 years
    I didn't understand your scenario, JSON.stringify will simply convert the object to string. Even if you don't have the same properties order it won't work !
  • Mehdi Benmoha
    Mehdi Benmoha over 5 years
    This is why I said that this is a dirty but working code. Because it has a lot of limits but it's fast AF.
  • Nik Handyman
    Nik Handyman over 4 years
    @Harsimer swap comparer and key function
  • Den Kerny
    Den Kerny about 4 years
    Class constructor Subscriber cannot be invoked without 'new' at new DistinctUntilChangedSubscriber
  • Frik
    Frik over 3 years
    even though it works, it messes with the pipeline objects
  • Ben Winding
    Ben Winding over 3 years
    @Frik how so? JSON.stringify() shouldn't mess/mutate anything right? If you have circular json in your objects, then you might need to use another package which can stringify it properly npmjs.com/package/circular-json
  • Frik
    Frik over 3 years
    you are correct. However, there seems to be a bug with WebStorm where it does not see the value o in the tap operator... so you don't have code completion for some reason... from([ { name: 'Brian' }, ]).pipe( distinctUntilChangedObj(), tap(o => console.log(o.name)) ).subscribe();
  • Ben Winding
    Ben Winding over 3 years
    If you're using typescript, then you could also do function distinctUntilChangedObj<T>() { return distinctUntilChanged<T>((a, b) => JSON.stringify(a) === JSON.stringify(b)); }. This will preserve the type correctly, but only if you're using typescript
  • Reza
    Reza over 3 years
    JSON.stringify({a:1, b:2}) === JSON.stringify({b:2, a:1}) this is false, while the result should have been ture
  • Mehdi Benmoha
    Mehdi Benmoha over 3 years
    @Reza yes that’s why I said dirty, because it will check properties order. But it solves majority of cases, where you have exact same objects, you can consider this solution like ===
  • pjdicke
    pjdicke over 3 years
    Nice answer! This will be very useful.
  • pjdicke
    pjdicke over 3 years
    FYI, if name was an object like {name: {first: 'Brian'}} this doesn't work as it doesn't compare the the entire objects.
  • TamusJRoyce
    TamusJRoyce about 2 years
    Just gonna repeat #Reza. Not a fan of "opinion" that this is ok because it is "dirty" - JSON.stringify({a:1, b:2}) === JSON.stringify({b:2, a:1}) this is false, while the result should have been true - Reza
  • TamusJRoyce
    TamusJRoyce about 2 years
    Just gonna repeat #Reza. Not a fan of "opinion" that this is ok because it is "dirty" - JSON.stringify({a:1, b:2}) === JSON.stringify({b:2, a:1}) this is false, while the result should have been true - Reza
  • TamusJRoyce
    TamusJRoyce about 2 years
    there is structuredClone polyfil/new standard. Why isn't there structuredEquals replacement for lodash? lodash isn't compiler friendly when it comes to tree shaking
  • Mehdi Benmoha
    Mehdi Benmoha about 2 years
    @TamusJRoyce using the second option that sorts the string solves this order issue with more costs (it's slower) but in major cases you dont need to sort the object.
  • Ben Winding
    Ben Winding about 2 years
    That's true @TamusJRoyce, there's other ways it can fail too, for example circular references in the objects. I've updated answer to address these problems
  • Bobby_Cheh
    Bobby_Cheh about 2 years
    Can you clarify what you mean in the sentence: "Or if you have _ available (which is not recommended anymore these days):" because you literally just said if you have lodash in your application you can utilize lodash's isEqual() function, but isn't using "_" the way to use lodash to call the isEqual method??
  • hoeni
    hoeni about 2 years
    @Bobby_Cheh When you import the _-Symbol, this includes all of lodash's functionality, whether you use it or not. This cannot be reduced to the really needed ones later to reduce the resulting bundle size in a build process (this is called "Tree Shaking", because all unconnected things fall of :-) ). When you just require the single functions you need, it's only theses that get bound to your final program bundle: e.g. import {isEqual} from 'lodash'. Now it's tree-shakeable, so all the functions of lodash you didn't use (probably most part of lodash ;-) ) will not be included.
  • TamusJRoyce
    TamusJRoyce about 2 years
    @MehdiBenmoha still messes up objects like dates and such. Better to stick with a robust method then serialization. Ben's solution is deep-object-diff. With structuredClone, I am surprised to not see a likewise proper standard deep compare.