Observable Pass-by-Reference Gotcha
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:
- Viewed records for
Record1234
- Made some updates to
Record1234
, ex:name='Record Foo'
- Viewed the updates
- 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
- Get empty record during
RecordA
workflow - Modify empty record
- Get same empty record in
RecordB
workflow - 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."));