Elagin Vladislav
Elagin Vladislav

Reputation: 139

Changes to object made with Object.assign mutates source object

I have following reducer code in my react-redux app:

    case 'TOGGLE_CLIENT_SELECTION':
        const id = action.payload.id;
        let newState = Object.assign({}, state);
        newState.list.forEach((client) => {
            if (client.id == id) client.selected = !client.selected;
        });
        console.log(state.list[0].selected + ' - ' + newState.list[0].selected)
        return newState;

If I got it right - Object.assign creates brand new object, but console.log displays "true - true" of "false - false". Any thoughts why it behaves like this and how can I avoid this behavior?

Upvotes: 9

Views: 7643

Answers (5)

JDB
JDB

Reputation: 25835

Object.assign({}, ...) creates a shallow clone

Object.assign({}, state) will copy properties from state. String, number and boolean properties are copied "by value", while object properties (including Dates) are "by reference", which means the new properties point to the same objects as the old properties.

So, for example, given:

const otherState = { count: 10 };

const state = {
  text: "foo",
  otherState: otherState,
};

const newState = Object.assign({}, state);

newState.text = "bar";
newState.otherState.count = 9;

newState.text will have a brand new string assigned to it, and state will not be affected. But both state and newState refer to the same instance of otherState, so when you updated count to 9, both state.otherState.count and newState.otherState.count are affected:

state = {
  text = "foo"
  otherState ──────┐
}                  │
                   │
                   │
                   ├────> { count = 9 }
                   │
newState = {       │
  text = "bar"     │
  otherState ──────┘
}

When using Object.assign, use the rule of three: if you get to the third property, you are now working in "shared" state:

newState.otherState.count = 0;
//  ^        ^        ^
//  ╵        ╵        ╵
//  1        2        3

JSON.parse to the rescue (?)

A quick and easy work around, as suggested by Tim, is to use JSON.stringify:

let newState = JSON.parse(JSON.stringify(state));

But this is not foolproof. It'll work for some simple scenarios, but there are many scenarios that might fail. For example, circular references break JSON.stringify:

let x = { id: "x" },
y = { id: "y" };
x.y = y;
y.x = x;

// fails with: "Uncaught TypeError: Converting circular structure to JSON"
let cloneX = JSON.parse(JSON.stringify(x));

Custom cloning code to the rescue (?)

If you know the structure that you're trying to clone, then you can use your own logic to handle the copying and not fall into endless loops:

function cloneX( x ){
    const c_x = { id: x.id };
    const c_y = { id: x.y.id };
    c_x.y = c_y;
    c_y.x = c_x;
    return c_x;
}

let x = { id: "x" },
    y = { id: "y" };
x.y = y;
y.x = x;

let x2 = cloneX(x);
x2.y.id = "y2";

console.log( `x2.y.id was updated: ${x2.y.id}` );
console.log( `x.y.id is unchanged: ${x.y.id}` );

It's also conceivable that, with some creative use of WeakMap, you could come up with some logic that handles unknown data structures by tracking recursion and allowing deep copies.

NPM to the rescue (?)

It'd probably be easier to just use a library, though.

Upvotes: 12

You can use the following to make a deep copy and then use Object.asign with it:

const x = { id: "x" },
y = { id: "y" };
x.y = y;
y.x = x;
const cloneX = Object.fromEntries(Object.entries(x));

const cloneChangeX = Object.assign(cloneX, {z: {id: "z"}});

console.log('const x = ', x, '\nconst cloneX = ', cloneX, '\nconst cloneChangeX = ', cloneChangeX);

Finally, you can do:

const cloneChangeX = Object.assign(
  Object.fromEntries(Object.entries(x)),
  { changekey: changevalue }
);

Upvotes: 0

Noel Yap
Noel Yap

Reputation: 19788

According to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign, Object.assign modifies the target object rather than creating a new one.

Upvotes: 1

hakuna
hakuna

Reputation: 6701

Try this : let newState = state.map(item => Object.assign({}, ...item)) This will create a new object without any reference to the old object state

Upvotes: 2

Tim Consolazio
Tim Consolazio

Reputation: 4888

True, but it's not a deep copy.

The new object contains a reference to the old list.

Here's a trick to get around it (there's more "proper" ways some might say):

JSON.stringify the original. Then JSON.parse that string. The new object will be a "deep" copy (not sure if that's really technically "deep copying"). It works fine unless your sub-types are something more complex than standard plain old JSON-acceptable stuff.

Upvotes: 8

Related Questions