It seems like when using redux with react, immutable.js has become almost a industry standard. My question is, aren't we making changes to our redux states immutably when we are using the spread operator? For example,
const reducer = (state=initialState, action) => {
switch(action.type){
case actionType.SOME_ACTION:
return {
...state,
someState: state.someState.filter(etc=>etc)
}
}
Isn't the way I am setting the state with redux immutable? What is the benefit of using immutable.js OVER spread operator way of making objects immutable?
Apologies if this question has been asked, but I couldn't find the answer that satisfied me. I understand the benefits of immutable objects, but not the importance of using the immutable.js library over the dot operator.
Yes! The ES6 spread operator can be used as a replacement for immutable.js entirely, but there is one major caveat, you must maintain situational awareness at all times.
You, and your fellow developers, will be 100% responsible for maintaining immutability, rather than letting immutable.js take care of it for you. Here's a breakdown of how you can manage an immutable state all by yourself using the ES6 'spread operator', and its various functions like filter
and map
.
The following will explore removing and adding values to an array or object in an immutable and mutated way. I log out the initialState
and newState
in each example to demonstrate if we've mutated initialState
. The reason this is important, is because Redux will not instruct the UI to re-render if the initialState
and newState
are exactly the same.
Note: Immutable.js would crash the application if you tried any of the mutated solutions below.
const initialState = {
members: ['Pete', 'Paul', 'George', 'John']
}
const reducer = (state, action) => {
switch(action.type){
case 'REMOVE_MEMBER':
return {
...state,
members: state.members.filter(
member => member !== action.member
)
}
}
}
const newState = reducer(
initialState,
{type: 'REMOVE_MEMBER', member: 'Pete'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: ['Pete', 'Paul', 'George', 'John']
}
const reducer = (state, action) => {
switch(action.type){
case 'REMOVE_MEMBER':
state.members.forEach((member, i) => {
if (member === action.member) {
state.members.splice(i, 1)
}
})
return {
...state,
members: state.members
}
}
}
const newState = reducer(
initialState,
{type: 'REMOVE_MEMBER', member: 'Pete'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: ['Paul', 'George', 'John']
}
const reducer = (state, action) => {
switch(action.type){
case 'ADD_MEMBER':
return {
...state,
members: [...state.members, action.member]
}
}
}
const newState = reducer(
initialState,
{type: 'ADD_MEMBER', member: 'Ringo'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: ['Paul', 'George', 'John']
}
const reducer = (state, action) => {
switch(action.type){
case 'ADD_MEMBER':
state.members.push(action.member);
return {
...state,
members: state.members
}
}
}
const newState = reducer(
initialState,
{type: 'ADD_MEMBER', member: 'Ringo'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: ['Paul', 'Pete', 'George', 'John']
}
const reducer = (state, action) => {
switch(action.type){
case 'UPDATE_MEMBER':
return {
...state,
members: state.members.map(member => member === action.member ? action.replacement : member)
}
}
}
const newState = reducer(
initialState,
{type: 'UPDATE_MEMBER', member: 'Pete', replacement: 'Ringo'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: ['Paul', 'Pete', 'George', 'John']
}
const reducer = (state, action) => {
switch(action.type){
case 'UPDATE_MEMBER':
state.members.forEach((member, i) => {
if (member === action.member) {
state.members[i] = action.replacement;
}
})
return {
...state,
members: state.members
}
}
}
const newState = reducer(
initialState,
{type: 'UPDATE_MEMBER', member: 'Pete', replacement: 'Ringo'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: ['Paul', 'Ringo']
}
const reducer = (state, action) => {
switch(action.type){
case 'MERGE_MEMBERS':
return {
...state,
members: [...state.members, ...action.members]
}
}
}
const newState = reducer(
initialState,
{type: 'MERGE_MEMBERS', members: ['George', 'John']}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: ['Paul', 'Ringo']
}
const reducer = (state, action) => {
switch(action.type){
case 'MERGE_MEMBERS':
action.members.forEach(member => state.members.push(member))
return {
...state,
members: state.members
}
}
}
const newState = reducer(
initialState,
{type: 'MERGE_MEMBERS', members: ['George', 'John']}
);
console.log('initialState', initialState);
console.log('newState', newState);
The above examples of mutating an array may seem like obvious bad practices to a seasoned developer, but an easy pitfall for someone new on the scene. We'd hope that any of the Mutated way code snippets would get caught in code review, but that's not always the case. Let's talk a little bit about objects, which are more cumbersome when handling immutability on your own.
const initialState = {
members: {
paul: {
name: 'Paul',
instrument: 'Guitar'
},
stuart: {
name: 'Stuart',
instrument: 'Bass'
}
}
}
const reducer = (state, action) => {
switch(action.type){
case 'REMOVE_MEMBER':
let { [action.member]: _, ...members } = state.members
return {
...state,
members
}
}
}
const newState = reducer(
initialState,
{type: 'REMOVE_MEMBER', member: 'stuart'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: {
paul: {
name: 'Paul',
instrument: 'Guitar'
},
stuart: {
name: 'Stuart',
instrument: 'Bass'
}
}
}
const reducer = (state, action) => {
switch(action.type){
case 'REMOVE_MEMBER':
delete state.members[action.member]
return {
...state,
members: state.members
}
}
}
const newState = reducer(
initialState,
{type: 'REMOVE_MEMBER', member: 'stuart'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: {
paul: {
name: 'Paul',
instrument: 'Guitar'
},
ringo: {
name: 'George',
instrument: 'Guitar'
}
}
}
const reducer = (state, action) => {
switch(action.type){
case 'CHANGE_INSTRUMENT':
return {
...state,
members: {
...state.members,
[action.key]: {
...state.members[action.member],
instrument: action.instrument
}
}
}
}
}
const newState = reducer(
initialState,
{type: 'CHANGE_INSTRUMENT', member: 'paul', instrument: 'Bass'}
);
console.log('initialState', initialState);
console.log('newState', newState);
const initialState = {
members: {
paul: {
name: 'Paul',
instrument: 'Guitar'
},
ringo: {
name: 'George',
instrument: 'Guitar'
}
}
}
const reducer = (state, action) => {
switch(action.type){
case 'CHANGE_INSTRUMENT':
state.members[action.member].instrument = action.instrument
return {
...state,
members: state.members
}
}
}
const newState = reducer(
initialState,
{type: 'CHANGE_INSTRUMENT', member: 'paul', instrument: 'Bass'}
);
console.log('initialState', initialState);
console.log('newState', newState);
If you've made it down this far, congratulations! I know this has been a long winded post, but I felt it was important to demonstrate all the Mutated ways that you'll need to prevent yourself without Immutable.js. One huge advantage to using Immutable.js, beyond preventing you from writing bad code, is the helper methods, like mergeDeep
and updateIn
const initialState = Immutable.fromJS({
members: {
paul: {
name: 'Paul',
instrument: 'Guitar'
},
ringo: {
name: 'George',
instrument: 'Guitar'
}
}
})
const reducer = (state, action) => {
switch (action.type) {
case 'ADD_MEMBERS':
return state.mergeDeep({members: action.members})
}
}
const newState = reducer(
initialState,
{
type: 'ADD_MEMBERS',
members: {
george: { name: 'George', instrument: 'Guitar' },
john: { name: 'John', instrument: 'Guitar' }
}
}
);
console.log('initialState', initialState);
console.log('newState', newState);
<script src="https://cdnjs.cloudflare.com/ajax/libs/immutable/3.8.2/immutable.min.js"></script>
const initialState = Immutable.fromJS({
members: {
paul: {
name: 'Paul',
instrument: 'Guitar'
},
ringo: {
name: 'George',
instrument: 'Guitar'
}
}
})
const reducer = (state, action) => {
switch (action.type) {
case 'CHANGE_INSTRUMENT':
return state.updateIn(['members', action.member, 'instrument'], instrument => action.instrument)
}
}
const newState = reducer(
initialState,
{type: 'CHANGE_INSTRUMENT', member: 'paul', instrument: 'Bass'}
);
console.log('initialState', initialState);
console.log('newState', newState);
<script src="https://cdnjs.cloudflare.com/ajax/libs/immutable/3.8.2/immutable.min.js"></script>
Isn't the way I am setting the state with Redux immutable?
In your example code (assuming the real function passed to filter
doesn’t do any mutation), yes.
What is the benefit of using immutable.js OVER spread operator way of making objects immutable?
Two major reasons:
It’s not (easily) possible to accidentally mutate an Immutable collection object, as the public API does not permit it. Whereas with built-in JS collections, it is. Deep-freezing (recursively calling Object.freeze
) can help this a bit.
Efficient* use of immutable updates with built-in collections can be challenging. Immutable.js uses tries internally to make updates more efficient than is possible with vanilla-usage of native collections.
If you want to use built-in collections, consider using Immer, which gives a nicer API for immutable updates while also freezing the objects it creates, helping mitigate the first issue (but not the second).
* Efficient meaning time complexity of e.g. object construction and GC runs due to increased object churn.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With