Immutably setting a value in a JS array (or how an array is also an object) #
Reducers in Redux are a place where functional programming, and the concept of immutability, are very noticeable. Immutability is not natural in Javascript, so some resort to libraries like Immutable. Me? I prefer native arrays, objects, and primitives, to library-defined data-structures — so I resort to various ES6 patterns to implement immutability.
My problem is that object immutability is pretty easy, but array immutability is harder. To understand the problem, and the context of the problem, let’s do a recap of theRedux immutability problem.
Redux Reducers and Immutability #
In Redux, a reducer is a function that accepts the “state” of the application, an “action”, and returns the new state. Let’s give an example:
const intReducer = (state, action) => {
switch (action.type) {
case 'increment':
return state + action.delta
case 'decrement':
return state - action.delta
}
}
Examples of usage:
console.log(intReducer(5, {type: 'increment', delta: 2})) // 7
console.log(intReducer(5, {type: 'decrement', delta: 2})) // 3
Let’s try a more complex example, where the state is an object:
const personReducer = (state, action) => {
switch (action.type) {
case 'upperCaseFirstName':
return Object.assign({}, state,
{firstName: state.firstName.upper()})
case 'upperCaseLastName':
return Object.assign({}, state,
{lastName: state.lastName.toUpper()})
}
}const person = {firstName: 'Gil', lastName: 'Tayar'}
console.log(personReducer(person, {type: 'upperCaseFirstName'}))
// {fistName: 'GIL', lastName: 'Tayar'}
Note the use of Object.assign
to create a new state, and not change the previous one. Object.assign
is perfect for that, and when JS gets the new spread operator for objects, this method will be even nicer:
{...state, firstName: state.firstName.upper}
Immutability for Arrays #
But what if my state is an array? Let’s grab an example, straight from TodoMVC:
const todoReducer = (state, action) => {
switch (action.type) {
case 'markDone':
const newState = [...state] // clone the array
newState[action.index].done = true
return newState
}
}const todos = [{title: 'laundry', done: false}, {title: 'dishes', done: false}]
console.log(todoReducer(todos, {type: 'markDone', index: 1}))
// [{title: 'laundry', done: false},
// {title: 'dishes', done: true}]
Ouch. That hurts! Instead of a nice one-liner, I have procedural and non-functional code. Specifically I’m looking at this:
const newState = [...state] // clone the array
newState[action.index].done = true
return newState
I could do this:
return [...state.slice(0, action.index),
Object.assign({}, state[action.index], {done: true}),
...state.slice(action.index + 1)]
I’m using Array.slice
and the spread operator, but I’m pretty sure that the cure is not worse than the disease!
Solution #
I was reading the emails from the esdiscuss list, and found this question:
Proposal: Specifying an Index in Array Literals
Aside: If you’re interested in the future of Javascript, or want to understand Javascript in depth, then the es-discuss mailing list is for you. (Update (Dec 2017): unfortunately, like many good things on the Internet, this mailing list has become a bit of a bore. Many trolls, many discussions leading nowhere.)
Aside: there is now a proposal to include an immutable
splice
(and other operations) to JavaScript, here.
Jeremy Martin wanted a way to set a value in an array immutably, and proposed a future feature that enabled this feature.
Bam! Exactly like what I was looking for. But this question quickly got an answer which, (i) blew my mind, and (ii) made me think “now why didn’t I think about it?”. Let’s use the method, and write some code that changes the 3rd element in an array, immutably:
const array = ['a', 'b', 'c', 'd']
console.log(Object.assign([...array], {2: 'x'}))
// ['a','b','x','d']
Huh? Object.assign
? And we’re using it on an array? Let’s ignore that strangeness for a second, and figure out what we’re doing here:
First, we’re cloning the array: [...array]
. Then we’re overriding the object that is the array, with {2: 'x'}
. And lo and behold — it works.
Arrays are objects #
Why does this work? Because arrays are objects. In some ways, the array ['a', 'b']
is similar to the object
{0: 'a', 1: 'b', length: 2}
For example, this code will work for both:
for (let i = 0; i < a.length; ++i)
console.log(a[i])
Also, this code will work for both:
a.foo = 'hi'
console.log(a.foo) // hi
But Objects can only be array-like #
So an array is an object. But can an object be an array?
Nope. For example:
console.log(a.length) // 2
a[2] = 'c'
console.log(a.length) // ???
If a
is an array, a.length
will get updated from 2
to 3
. But try it on the object we created above, and a.length
will remain 2
.
Also this code won’t work for the object:
for (let i in a)
console.log(a[i])
And lots more code won’t work. Because an array is an object, but an object can only be array-like. It can have the same fields as an array, but it doesn’t have the Array behavior.
Why the solution works #
Let’s go back to our example:
const array = ['a', 'b', 'c', 'd']
console.log(Object.assign([...array], {2: 'x'}))
Since we can think of an array as an object, whose keys are the array indexes, we can use Object.assign
to set those “keys”, which is exactly what we’re doing here.
Back to our reducer #
If we want to write a function that sets a value on the index immutably:
const setArrayImmutable = (arr, i, value) =>
Object.assign([...arr], {[i]: value})
Note the use of the ES6 syntax for defining a field key using an expression[i]: value
.
Now we can do something similar in our reducer:
const todoReducer = (state, action) => {
switch (action.type) {
case 'markDone':
return Object.assign(
[...state],
{[action.index]:
Object.assign({}, state[action.index], {done: true}))
}
}
And that does the trick. Now changing an array element immutably is as simple as changing a property in an object immutably.