Immutability is a fundamental concept in JavaScript, especially when working with state management in frameworks like React or Redux. The idea is to ensure that data is not directly modified, but instead, new copies of the data structures are created with the required changes. This helps in maintaining predictable state and avoiding unintended side effects.
Using immutability helpers in JavaScript provides numerous benefits such as improving performance, ensuring predictable state updates, enabling time-travel debugging, and maintaining data integrity in concurrent applications. Libraries like immer and immutability-helper are popular tools that assist developers in managing immutable data structures efficiently.
https://bigfrontend.dev/problem/implement-Immutability-helper
If you use React, you would meet the scenario to copy the state for a slight change.
For example, for following state
const state = {
a: {
b: {
c: 1,
},
},
d: 2,
};
if we are to modify d
to a new state, we could use _.cloneDeep, but it is not efficient because state.a
is cloned while we don't need to change that.
A better way is to do shallow copy like this
const newState = {
...state,
d: 3,
};
now is the problem, if we want to modify c
, we would have to do something like
const newState = {
...state,
a: {
...state.a,
b: {
...state.b,
c: 2,
},
},
};
We can see that for simple data structure it would be enough to use spread operator, but for complex data structures, it is verbose.
Here comes the Immutability Helper, you are asked to implement your own Immutability Helper update()
, which supports following features.
1. {$push: array} push() all the items in array on the target.
const arr = [1, 2, 3, 4];
const newArr = update(arr, { $push: [5, 6] });
// [1, 2, 3, 4, 5, 6]
2. {$set: any} replace the target
const state = {
a: {
b: {
c: 1,
},
},
d: 2,
};
const newState = update(state, { a: { b: { c: { $set: 3 } } } });
/*
{
a: {
b: {
c: 3
}
},
d: 2
}
*/
Notice that we could also update array elements with $set
const arr = [1, 2, 3, 4];
const newArr = update(arr, { 0: { $set: 0 } });
// [0, 2, 3, 4]
3. {$merge: object} merge object to the location
const state = {
a: {
b: {
c: 1,
},
},
d: 2,
};
const newState = update(state, { a: { b: { $merge: { e: 5 } } } });
/*
{
a: {
b: {
c: 3,
e: 5
}
},
d: 2
}
*/
4. {$apply: function} custom replacer
const arr = [1, 2, 3, 4];
const newArr = update(arr, { 0: { $apply: (item) => item * 2 } });
// [2, 2, 3, 4]
/**
* @param {any} data
* @param {Object} command
*/
function update(data, command) {
if (typeof data !== 'object' && !Array.isArray(data)) {
throw new Error();
}
let copiedData = copy(data);
_update(copiedData, command);
return copiedData;
}
function _update(data, command) {
for (const key in command) {
if (key === '$push' && Array.isArray(command[key]) && Array.isArray(data)) {
data.push(...command[key]);
return;
}
if (
typeof command[key] === 'object' &&
command[key].hasOwnProperty('$set')
) {
data[key] = command[key].$set;
return;
}
if (
typeof command[key] === 'object' &&
command[key].hasOwnProperty('$apply') &&
Array.isArray(data)
) {
if (data[key]) {
data[key] = command[key].$apply(data[key]);
return;
}
}
if (
typeof command[key] === 'object' &&
command[key].hasOwnProperty('$merge')
) {
if (typeof data[key] === 'object') {
data[key] = {
...data[key],
...command[key].$merge,
};
return;
} else {
throw new Error();
}
}
if (typeof command[key] === 'object') {
_update(data[key], command[key]);
}
}
}
function copy(data) {
let newData;
if (Array.isArray(data)) {
newData = [];
for (const el of data) {
if (Array.isArray(el) || typeof el === 'object') {
newData.push(copy(el));
} else {
newData.push(el);
}
}
} else if (typeof data === 'object') {
newData = {};
for (const key in data) {
if (typeof data[key] === 'object' || Array.isArray(data[key])) {
newData[key] = copy(data[key]);
} else {
newData[key] = data[key];
}
}
}
return newData;
}
const data = {
a: {
b: {
c: 1,
},
},
};
const command = {
a: {
b: {
$set: {
c: 2,
},
},
},
};
const updatedData = update(data, command);
console.log(updatedData); // { a: { b: { c: 2 } } }
Sure, let's break down this code:
-
copy
function: This function takes an inputdata
and creates a deep copy of it. It checks if the data is an array or an object and recursively copies all elements or properties. This is done to avoid mutating the original data. -
_update
function: This is a helper function that takes the copied data and a command object. It iterates over the command object and checks for specific keys that determine how to update the data:- If the key is
$push
and both the command[key] and data are arrays, it pushes the elements of command[key] into the data array. - If the command[key] is an object and has a
$set
property, it sets the data[key] to the value of command[key].$set. - If the command[key] is an object and has a
$apply
property and data is an array, it applies the function command[key].$apply to data[key] if it exists. - If the command[key] is an object and has a
$merge
property, it merges the properties of command[key].$merge into data[key] if data[key] is an object. - If the command[key] is an object and doesn't match any of the above conditions, it recursively calls
_update
on data[key] and command[key].
- If the key is
-
update
function: This function is the main function that users interact with. It takes data and a command as input. It first checks if the data is an object or an array, throwing an error if it's not. It then creates a copy of the data and calls_update
on the copied data and the command. Finally, it returns the updated copy. -
Usage: The usage example shows how to use the
update
function. It creates adata
object and acommand
object that instructs the function to setdata.a.b.c
to 2. After callingupdate(data, command)
, it logs the updated data, which is{ a: { b: { c: 2 } } }
.
This code provides a way to update JavaScript objects or arrays in a declarative way, specifying what updates to make in the form of a command object. It's similar to how state updates are handled in MongoDB or in the React setState
function.
Here are some real-world examples and use cases where immutability helpers are beneficial:
React uses a virtual DOM and requires components to re-render efficiently. Immutable state updates help React to optimize these updates. Here’s an example using the immer
library:
import produce from 'immer';
const initialState = {
todos: [{ text: 'Learn JavaScript', completed: false }]
};
const newState = produce(initialState, draft => {
draft.todos[0].completed = true;
});
console.log(newState); // { todos: [{ text: 'Learn JavaScript', completed: true }] }
console.log(initialState); // { todos: [{ text: 'Learn JavaScript', completed: false }] }
In Redux, immutability is crucial as reducers must be pure functions. Here’s an example using immutability-helper
:
import update from 'immutability-helper';
const state = {
todos: ['Learn Redux']
};
const newState = update(state, {
todos: { $push: ['Implement immutability-helper'] }
});
console.log(newState); // { todos: ['Learn Redux', 'Implement immutability-helper'] }
console.log(state); // { todos: ['Learn Redux'] }
By ensuring objects are immutable, React components can use shallow comparison to determine if re-renders are necessary, improving performance.
class TodoList extends React.Component {
shouldComponentUpdate(nextProps) {
return nextProps.todos !== this.props.todos;
}
render() {
return (
<ul>
{this.props.todos.map(todo => <li key={todo}>{todo}</li>)}
</ul>
);
}
}
Immutability is a core principle of functional programming, helping to avoid side effects. Here's an example using native JavaScript methods:
const array = [1, 2, 3];
const newArray = [...array, 4];
console.log(newArray); // [1, 2, 3, 4]
console.log(array); // [1, 2, 3]
In environments where multiple threads or processes might access and modify data, immutability ensures that data remains consistent and prevents race conditions.
Immutability helps implement undo/redo functionality in applications, such as text editors or drawing tools:
const pastStates = [];
const presentState = { text: 'Hello' };
const futureStates = [];
function undo() {
if (pastStates.length === 0) return;
futureStates.push(presentState);
presentState = pastStates.pop();
}
function redo() {
if (futureStates.length === 0) return;
pastStates.push(presentState);
presentState = futureStates.pop();
}
In applications where the state needs to be predictable and debuggable, immutability ensures that state transitions are clear and traceable.
const state = {
user: {
name: 'Alice',
age: 25
}
};
const newState = {
...state,
user: {
...state.user,
age: 26
}
};
console.log(state); // { user: { name: 'Alice', age: 25 } }
console.log(newState); // { user: { name: 'Alice', age: 26 } }
In collaborative apps where multiple users can modify data simultaneously, immutability helps maintain data integrity.
const doc = {
content: 'Hello World'
};
const newDoc = {
...doc,
content: 'Hello Collaborative World'
};
console.log(doc); // { content: 'Hello World' }
console.log(newDoc); // { content: 'Hello Collaborative World' }