To deep copy or not to deep copy - and why should ngrx's state be immutable, anyway?
Asked Answered
Q

2

18

I'm new to ngrx (and never used redux) and am trying to make sense of it all - especially whether you need deep copies of the state. Here's what I've learned so far and what still confuses me (further down in bold).

The ngrx docs state that

Each reducer function takes the latest Action dispatched, the current state, and determines whether to return a newly modified state or the original state.

They also point out that state transitions need to happen immutably - if you change state, it needs to be on a copy:

Each action handles the state transition immutably. This means that the state transitions are not modifying the original state, but are returning a new state object using the spread operator.

They don't say why that is necessary, though, beyond promoting referential integrity (and I'm not quite sure how making sure your references are pointing to data - I only know the term from relational database contexts - plays into it all):

The spread syntax copies the properties from the current state into the object, creating a new reference. This ensures that a new state is produced with each change, preserving the purity of the change. This also promotes referential integrity, guaranteeing that the old reference was discarded when a state change occurred.

That (referential integrity and the section above) doesn't really explain why that is necessary, though.
Elsewhere on SO, I found a comment suggesting it allows to change Angular's change detection strategy to OnPush.

(I was also somewhat baffled by how, if an action can trigger several reducers, the resulting state copies can be consolidated, but that's apparently explained by each reducer exclusively looking after a separate slice of the state and redux being aware of that.)

The important thing seems to be, though, that a copy - shallow or deep - of the state creates a new reference, and that means ngrx is pushing a change to its subscribers:

If the returned object reference has changed, it will trigger any related RxJS state subscriptions for the particular piece of state in question. Which subscriptions are triggered can be minimised using some good ngrx selectors.

Generally speaking, the Redux FAQ have a list of why immutability is a good thing and why redux requires it:
increased performance, simpler debugging, easier reasoning, safer data handling, shallow equality checking

They also say that it

enables sophisticated change detection techniques to be implemented simply and cheaply, ensuring the computationally expensive process of updating the DOM occurs only when it absolutely has to

As just pointed out (as one of the requirements for immutability) redux does shallow equality checking.

Nrgx's docs, however, recommend deep cloning (plus, the state isn't truly immutable if the copy references old objects, I suppose):

Note: The spread operator only does shallow copying and does not handle deeply nested objects. You need to copy each level in the object to ensure immutability. There are libraries that handle deep copying including lodash and immer.

However, deep copies might have "nasty" side effects (when using the cloned items in an Angular component, say):

This question even extends to the way Angular's ngFor change detection works (and using a trackBy function complicates that even further!): when I clone every item in Thing[] and have my reducer return a new list of cloned Things, Angular will think it's a brand new list (which it technically is) and run change-detection for all items in the list: They will also be brand new, and as such, old list items get removed and new ones get added to the DOM.

Suppose you have a ThingComponent for each Thing in the ngFor list. In that component, ngOnChanges will fire. But here's the thing: the SimpleChanges passed to ngOnChanges will never contain previousValues, because the whole list got replaced, and so there is previous value: everything is brand new, from Angulars perspective.

The author also points to a solution (trackBy), but I'm now wondering:
is using deep copy really a good idea with ngrx (and do you really need deep copies if all that is required to make the library work is a new object reference for the root/state object)? The last quote sounds a bit like it would be a better idea to only swap the root object, the state, out, to get a new reference that then triggers subscriptions, but leave the nested objects - lists, especially - alone.

Quilmes answered 3/1, 2020 at 11:34 Comment(7)
redux.js.org/recipes/structuring-reducers/… also points out that not returning completely immutable objects breaks time-travel debugging, and returning the same state/root breaks change detection ("hallow reference equality checks"), but that still doesn't explain why deep copies are necessary (if one doesn't need time travel).Quilmes
I've also brought this up on Gitter now: gitter.im/ngrx/platform?at=5ed666e8daf4cf366ed8c70eQuilmes
About 3 years into this, Did you get answers @Christian?Vite
@dewey and Harshal Seems to me the answer sufficiently answers the question. What uncertainty remains?Hinton
For me the core part of the question "why should ngrx's state be immutable, anyway?" is still unanswered for me. I would assume that the docs were written with best practices for performance in mind. So I would count "the state should be immutable" into that. But the first answer suggests to do the opposite. Personally I'm mutating my data 95% of the time and haven't run into any issues. I'm just curious if I'm missing out on something by mutating instead of keeping my data immutable.Easterly
@Easterly AFAIK Redux has always maintained that you should use immutable state updates, and the first answer here completely agrees with that. SatanTime's answer even addresses your "curious if I'm missing out on something by mutating instead of keeping my data immutable" part, e.g. mutating states makes it really difficult to diagnose state issues. It's only more recently with Redux-Toolkit that allows us to write/use mutatable state updates because it uses immer.js "under-the-hood" and we're just mutating a draft copy. Is it possible you have been using RTK?Hinton
@Vite I think it all hinges on whether the immutability requirement refers to the entire state or just your object (and its nested children). From what I've read, I'd currently assume the latter. I guess one could nowadays configure strict runtime checks in a sample project and just try. But I've never had a definitive answer, no.Quilmes
P
3
  1. Deep copy

Our goal to keep our apps as fast as possible what means to reduce calculation when it's not needed or redundant.

Angular has 2 change detection strategies onPush and Default, the first one checks pointers of variables passed into inputs, the second one does deep check and quite heavy on heavy objects. But they have one thing - they rerender only when data has been changed.

Deep copy is bad because it causes the same data to be presented under new object pointers, this causes render cycles and because data is the same rerendered result will be the same too, unless it's time dependent app.


  1. Immutable state

The best way for the data flow is from top to bottom, and bottom can notify top to change the data.

In such circumstances we can always find how data came here via checking parents and what they do with the data, and we have a single point where data is changing and we can find there who causes the change.

If we mutate data where we need the change instead of notifying the top, with time we can't easily find who does it anymore because these places will be everywhere in the code and we need to check all of them to find the issue.

Phenix answered 29/5, 2020 at 19:49 Comment(0)
M
0

Immutability and copy-strategy are not necessarily related.

Immutability expresses the notion of archiving, keeping track of historical changes. If an application starts with state A and the next change is applied in a mutable manner, we now hold state A with new change but old one is lost. In contrast, if that same change is applied in an immutable manner, we hold a state A' (new reference in memory) which references all nested object from state A except that one change to which we have a new reference. This way, we have separate state objects conducting an archive which can be used for, say, backwards investigation of user actions. Changing a nested property without creating a new reference for upstream-parents will also damage immutability since it will effect previous states.

Whether or not to deep-copy is a separate question related to developmental requirements. We could, in theory, deep-copy A to create A' and by that protect ourselves from "changing history", but if we follow developmental guidelines such as referencing last state's unchanged properties while creating new references for changed properties only (including upstream-parents), then we can skip deep-copy which can be expensive. For string objects, for example, it would have been very dangerous for a development platform to apply shallow-copy due to the fact string are core building blocks that should be kept safe.

Monocoque answered 23/2, 2024 at 8:40 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.