Code @ DanielLakes.com

Observable Pass-by-Reference Gotcha

Last updated: Oct 21, 2022
3 minutes
Original date: Oct 21, 2022

Recently I ran into an old familiar problem in new clothes: the pass-by-reference error, wearing a new Observable coat.

The Task

So, I was in the middle of setting up a UI view + edit workflow. Had a standard API call to populate the view. It was expected to sometimes fail, and I wanted to return an empty record, via of({} as Record), in that case to be able to initialize the view with some defaults.

Once this record was returned, details were displayed on screen. A form was able to modify some of the properties, which would be reflected on the view screen.

Where things got weird is if I worked on two back-to-back:

  1. Viewed records for Record1234
  2. Made some updates to Record1234, ex: name='Record Foo'
  3. Viewed the updates
  4. Switched to view records for Record9876

When I got to step 4, viewing the details for the other record (Recod9876), it would show my updates from the first record, i.e. name='Record Foo'.

The Debug

After checking and double checking my calls, I started adding logs to all-the-things. Partly to sanity check, partly to give myself some eventual breakpoints to hook to.

  • ✅ - First record API call fails, as expected
  • ✅ - Failed first API call logs ‘empty record’ return
  • ✅ - View first record works with defaults
  • ✅ - Edit form succeeds and updates record
  • ✅ - Second record API call fails, as expected
  • ✅ - Failed second call logs ‘empty record’ return
  • ❌ - View second record works

Of course, this told me nothing new. Great. But, looking over the logs, I realized I wasn’t outputting my returns in every instance. Let’s make sure everything is what it says…

The Gotcha

Remember that empty record I mentioned earlier? Turns out, passing an object through of() passes it by reference. (Here’s an fairly thorough StackOverflow post covering what exactly that means.) If you later make modifications to the object, any subscribers have access to those changes without any Observable events firing.

The problem is I had a class-level constant I was using to store my empty record. Less instantiation is normally good, right? But in this case, it bit me. Put simply

  1. Get empty record during RecordA workflow
  2. Modify empty record
  3. Get same empty record in RecordB workflow
  4. Note that it’s not empty.

The Fix

I ended up just refactoring how that workflow was behaving. But, if I had kept it, the fix was to just move the empty object creation to within the catchError call so that a new instance is always being returned.

Examples

You can see a semi-accurate running example here. You an also see a more concise example below:

import { from, of } from "rxjs";
import { map, tap } from "rxjs/operators";

const emptyObj$ = of({});
const call1$ = emptyObj$.pipe(
map((record) => {
record["name"] = "Call 1 Record";
return record;
})
);

const call2$ = emptyObj$.pipe(
tap((record) => console.log("Call 2 expecting empty record: ", record))
);

// outputs: Call 1 result is { name: 'Call 1 Record' }
call1$.subscribe((result) => console.log("Call 1 result is:", result));

// outputs: Call 2 expecting empty record: { name: 'Call 1 Record' }. Weird.
call2$.subscribe((result) => console.log("Weird."));