BehaviorSubject send the same state reference to all subscribers

Viewed 4771

In our Single Page Application we've developed a centralized store class that uses an RxJS behavior subject to handle the state of our application and all its mutation. Several components in our application are subscribing to our store's behavior subject in order to receive any update to current application state. This state is then bound to UI so that whenever state changes, UI reflect those changes. Whenever a component wants to change a part of the state, we call a function exposed by our store that does the required work and updates the state calling next on the behavior subject. So far nothing special. (We're using Aurelia as a framework which performs 2 way binding)

The issue we are facing is that as soon as a component changes it's local state variable it receives from the store, other components gets updated even if next() wasn't called on the subejct itself.

We also tried to subscribe on an observable version of the subject since observable are supposed to send a different copy of the data to all subscriber but looks like it's not the case.

Looks like all subject subscriber are receiving a reference of the object stored in the behavior subject.

import { BehaviorSubject, of } from 'rxjs'; 

const initialState = {
  data: {
    id: 1, 
    description: 'initial'
  }
}

const subject = new BehaviorSubject(initialState);
const observable = subject.asObservable();
let stateFromSubject; //Result after subscription to subject
let stateFromObservable; //Result after subscription to observable

subject.subscribe((val) => {
  console.log(`**Received ${val.data.id} from subject`);
  stateFromSubject = val;
});

observable.subscribe((val) => {
  console.log(`**Received ${val.data.id} from observable`);
  stateFromObservable = val;
});

stateFromSubject.data.id = 2;
// Both stateFromObservable and subject.getValue() now have a id of 2.
// next() wasn't called on the subject but its state got changed anyway

stateFromObservable.data.id = 3;
// Since observable aren't bi-directional I thought this would be a possible solution but same applies and all variable now shows 3

I've made a stackblitz with the code above. https://stackblitz.com/edit/rxjs-bhkd5n

The only workaround we have so far is to clone the sate in some of our subscriber where we support edition through binding like follow:

observable.subscribe((val) => {
  stateFromObservable = JSON.parse(JSON.stringify(val));
});

But this feels more like a hack than a real solution. There must be a better way...

3 Answers

Yes, all subscribers receive the same instance of the object in the behavior subject, that is how behavior subjects work. If you are going to mutate the objects you need to clone them.

I use this function to clone my objects I am going to bind to Angular forms

const clone = obj =>
  Array.isArray(obj)
    ? obj.map(item => clone(item))
    : obj instanceof Date
    ? new Date(obj.getTime())
    : obj && typeof obj === 'object'
    ? Object.getOwnPropertyNames(obj).reduce((o, prop) => {
        o[prop] = clone(obj[prop]);
        return o;
      }, {})
    : obj;

So if you have an observable data$ you can create an observable clone$ where subscribers to that observable get a clone that can be mutated without affecting other components.

clone$ = data$.pipe(map(data => clone(data)));

So components that are just displaying data can subscribe to data$ for efficiency and ones that will mutate the data can subscribe to clone$.

Have a read on my library for Angular https://github.com/adriandavidbrand/ngx-rxcache and my article on it https://medium.com/@adrianbrand/angular-state-management-with-rxcache-468a865fc3fb it goes into the need to clone objects so we don't mutate data we bind to forms.

It sounds like the goals of your store are the same as my Angular state management library. It might give you some ideas.

I am not familar with Aurelia or if it has pipes but that clone function is available in the store with exposing my data with a clone$ observable and in the templates with a clone pipe that can be used like

data$ | clone as data

The important part is knowing when to clone and not to clone. You only need to clone if the data is going to be mutated. It would be really inefficient to clone an array of data that is only going to be displayed in a grid.

The only workaround we have so far is to clone the state in some of our subscriber where we support edition through binding like follow:

I don't think I can answer that without rewriting your store.

const initialState = {
  data: {
    id: 1, 
    description: 'initial'
  }
}

That state object has deeply structured data. Everytime you need to mutate the state the object needs to be reconstructed.

Alternatively,

const initialState = {
   1: {id: 1, description: 'initial'},
   2: {id: 2, description: 'initial'},
   3: {id: 3, description: 'initial'},
   _index: [1, 2, 3]
};

That is about as deep of a state object that I would create. Use a key/value pair to map between IDs and the object values. You can now write selectors easily.

function getById(id: number): Observable<any> {
   return subject.pipe(
       map(state => state[id]),
       distinctUntilChanged()
   );
}

function getIds(): Observable<number[]> {
   return subject.pipe(
      map(state => state._index),
      distinctUntilChanged()
   );
}

When you want change a data object. You have to reconstruct the state and also set the data.

function append(data: Object) {
    const state = subject.value;
    subject.next({...state, [data.id]: Object.freeze(data), _index: [...state._index, data.id]});
}

function remove(id: number) {
    const state = {...subject.value};
    delete state[id];
    subject.next({...state, _index: state._index.filter(x => x !== id)});
}

Once you have that done. You should freeze downstream consumers of your state object.

const subject = new BehaviorSubject(initialState);

function getStore(): Observable<any> {
   return subject.pipe(
      map(obj => Object.freeze(obj))
   );
}

function getById(id: number): Observable<any> {
   return getStore().pipe(
      map(state => state[id]),
      distinctUntilChanged()
   );
}

function getIds(): Observable<number[]> {
   return getStore().pipe(
      map(state => state._index),
      distinctUntilChanged()
   );
}

Later when you do something like this:

stateFromSubject.data.id = 2;

You'll get a run-time error.

FYI: The above is written in TypeScript

The big logical issue with your example is that the object forwarded by the subject is actually a single object reference. RxJS doesn't do anything out of the box to create clones for you, and that is fine otherwise it would result in unnecessary operations by default if they aren't needed.

So while you can clone the value received by the subscribers, you're still not save for access of BehaviorSubject.getValue(), which would return the original reference. Besides that having same refs for parts of your state is actually beneficial in lots of ways as e.g arrays can be re-used for multiple displaying components vs having to rebuild them from scratch.

What you want to do instead is to leverage a single-source-of-truth pattern, similar to Redux, where instead of making sure that subscribers get clones, you're treating your state as immutable object. That means every modification results in a new state. That further means you should restrict modifications to actions, (actions + reducers in Redux) which construct a new state of the current plus the necessary changes and return the new copy.

Now all of that might sound like a lot of work but you should take a look at the official Aurelia Store Plugin, which is sharing pretty much the same concept as you have plus making sure that best ideas of Redux are brought over to the world of Aurelia.

Related