50

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
            }
        });
    }
}
BinaryButterfly
  • 18,137
  • 13
  • 50
  • 91
Daniel Suchý
  • 1,822
  • 2
  • 14
  • 19
  • Is `typeChangeStream` an Observable? Hard to tell what's wrong without seeing what code is creating that / etc. – Mark Pieszak - Trilon.io May 11 '16 at 20:34
  • Please have a look at http://stackoverflow.com/help/mcve for guidelines to deal with 'it does not work' questions. Basically post a minimally verifiable example that reproduces the error, and post the expected behaviour and how it is different from the current behavior. Talk about JSON stringifying you need to know that it is not a bullet proof method for checking equality of objects. {"a" : 2, "b":1} is for example different from {"b":1, "a":2} while these are the same objects – user3743222 May 11 '16 at 23:02
  • Sorry for that, i added more code. I don't need bullet proof object equality check, i am sure in this case object will be everytime i same order. – Daniel Suchý May 12 '16 at 05:25
  • did u solve this problem? i have the same with BehaviorSubject, and it emits prev and curr same result, wtf.. – Den Kerny Mar 06 '20 at 08:21
  • @DanielSuchý it would be good IMO to accept the answer that did work for you – Mehdi Benmoha Jan 29 '21 at 11:17

11 Answers11

67

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(''))

Mehdi Benmoha
  • 3,694
  • 3
  • 23
  • 43
  • 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 – user3701188 Sep 12 '18 at 21:35
  • 1
    Have you tried the throttling or debouncing rxJS operators ? – Mehdi Benmoha Sep 13 '18 at 10:03
  • 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 – user3701188 Sep 13 '18 at 20:05
  • 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. – cobolstinks Nov 01 '18 at 15:53
  • 1
    this will give you incorrect `true` when you `delete` a property and add it back. – Milad Nov 15 '18 at 05:00
  • 3
    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 Nov 20 '18 at 13:14
  • 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. – Mehdi Benmoha Nov 28 '18 at 08:56
  • 1
    `JSON.stringify({a:1, b:2}) === JSON.stringify({b:2, a:1})` this is false, while the result should have been ture – Reza Sep 07 '20 at 00:48
  • @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 === – Mehdi Benmoha Sep 07 '20 at 10:24
  • 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 Mar 10 '22 at 20:24
  • @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. – Mehdi Benmoha Mar 11 '22 at 13:34
  • @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. – TamusJRoyce Apr 12 '22 at 18:43
34

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),
hoeni
  • 3,031
  • 30
  • 45
  • 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?? – Bobby_Cheh Mar 25 '22 at 17:27
  • 3
    @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. – hoeni Mar 27 '22 at 10:49
25

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!

Daniel Suchý
  • 1,822
  • 2
  • 14
  • 19
14

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));
}
Ben Winding
  • 10,208
  • 4
  • 80
  • 67
  • even though it works, it messes with the pipeline objects – Frik Aug 17 '20 at 16:11
  • @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 https://www.npmjs.com/package/circular-json – Ben Winding Aug 18 '20 at 00:53
  • 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(); – Frik Aug 19 '20 at 06:33
  • 1
    If you're using typescript, then you could also do `function distinctUntilChangedObj() { return distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)); }`. This will preserve the type correctly, but only if you're using _typescript_ – Ben Winding Aug 19 '20 at 07:54
  • 1
    Nice answer! This will be very useful. – pjdicke Dec 18 '20 at 19:16
  • 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 Mar 10 '22 at 20:25
  • 1
    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 – Ben Winding Mar 12 '22 at 09:00
10

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);
Whisher
  • 31,320
  • 32
  • 120
  • 201
  • 1
    FYI, if `name` was an object like `{name: {first: 'Brian'}}` this doesn't work as it doesn't compare the the entire objects. – pjdicke Dec 18 '20 at 19:19
4

Mehdi's solution although fast but wouldn't work if the order is not maintained. Using one of deep-equal or fast-deep-equal libraries:

.distinctUntilChanged((a, b) => deepEqual(a, b))
Wildhammer
  • 2,017
  • 1
  • 27
  • 33
  • 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 – TamusJRoyce Mar 10 '22 at 20:27
3

The answers suggesting a deep compare all fail if the observable is actually emitting a modified version of the same object, as the "last" value that is passed to the comparison will be the same as the current, every time.

We've hit this using a BehaviorSubject where it just so happens that the same object is passed to .next() each time.

In this case, there is no solution using the default distinctUntilChanged, no matter what comparison function you use.

Nick Phillips
  • 76
  • 2
  • 4
0

If you change the value mutably, none of the other answers work. If you need to work with mutable data structures, you can use distinctUntilChangedImmutable, which deep copies the previous value and if no comparison function is passed in, will assert that the previous and the current values deep equals each other (not === assertion).

Tom
  • 4,776
  • 2
  • 38
  • 50
0

You can also make your own filter:

function compareObjects(a: any, b: any): boolean {
  return JSON.stringify(a) === JSON.stringify(b);
}

export function distinctUntilChangedObject() {
  return function<T>(source: Observable<T>): Observable<T> {
    return new Observable(subscriber => {
      let prev: any;
      let first = true;
      source.subscribe({
        next(value) {
          if (first) {
            prev = value;
            subscriber.next(value);
            first = false;
          } else if (!compareObjects(prev, value)) {
            prev = value;
            subscriber.next(value);
          }
        },
        error(error) {
          subscriber.error(error);
        },
        complete() {
          subscriber.complete();
        }
      });
    });
  };
}
AlexandruC
  • 3,527
  • 6
  • 51
  • 80
0

For those considering JsonStringify to be slow or unreliable (because of different property order) the example below might help for an object array. Its primary key is a property called 'id'.

same(prev: BasketItem[], next: BasketItem[]): boolean {
    if (prev?.length != next?.length)
        return false;

    // verify that all of the ids are contained in both arrays
    for (var i = 0; i < prev.length; i++) {
        if (!prev.some(x => x.id == next[i].id))
            return false;
        if (!next.some(x => x.id == prev[i].id))
            return false;
    }
    return true;
}

I call it a as follows

.pipe(distinctUntilChanged((prev, curr) => this.same(prev, curr)))

example for the object array :

[ {id:'d7273481-e99b-4d72-84eb-826fbf52e4e9' , price:'44.02'}

, {id:'11f125e3-c190-4177-a656-7b107a7d687c' , price:'54.88'} ]

Ole EH Dufour
  • 2,968
  • 4
  • 23
  • 48
0

I had to do this:

import { cloneDeep, isEqual } from 'lodash-es'

observable$.pipe(
  ... other stuff
  map(x => cloneDeep(x)),
  distinctUntilChanged(isEqual)
  ... other stuff
)

if x is getting mutated, cloneDeep seems to be necessary to make distinctUntilChanged work

danday74
  • 52,471
  • 49
  • 232
  • 283