Array(3).fill([]) gives you one array, three times
published
TL;DR
In fill(value), value is evaluated once, before fill runs, and fill writes that same value into every slot. For a number or a string that is what you want. For an array or an object it means every slot holds a reference to the same object, so grid[0][0] = 1 sets the first cell of every row, and buckets[0].push(x) pushes into all of them. Create a fresh element per slot with Array.from({ length: n }, () => []).
The problem
A nested fill grid is the classic way to hit it:
const grid = new Array(3).fill(new Array(3).fill(0))
grid[0][0] = 1
console.log(grid)
[ [ 1, 0, 0 ], [ 1, 0, 0 ], [ 1, 0, 0 ] ]
One assignment, three changed rows. The same thing happens with lists of buckets and with objects used as records:
const buckets = new Array(3).fill([])
buckets[0].push('a')
console.log(buckets)
console.log(buckets[0] === buckets[1], buckets[1] === buckets[2])
const users = new Array(2).fill({ name: '' })
users[0].name = 'Ada'
console.log(users)
[ [ 'a' ], [ 'a' ], [ 'a' ] ]
true true
[ { name: 'Ada' }, { name: 'Ada' } ]
The === line is the whole story: the slots are not three equal arrays, they are one array.
It is easy to miss because the array looks right until the first write. console.log(new Array(3).fill([])) prints [ [], [], [] ], which is exactly what three separate empty arrays would print too.
Why it happens
fill is an ordinary function call. Its argument is evaluated once, before fill runs, and fill then assigns that one value to each index. MDN spells out the consequence: “Note all elements in the array will be this exact value: if value is an object, each slot in the array will reference that object.”
Primitives hide this. Numbers, strings and booleans are immutable, so there is nothing to change through a slot; you can only replace the slot’s value, and replacing one slot leaves the others alone:
const row = new Array(3).fill(0)
row[0] = 1
console.log(row)
const words = new Array(2).fill('hi')
words[0] += '!'
console.log(words)
const bytes = new Uint8Array(4).fill(7)
bytes[0] = 1
console.log(bytes)
[ 1, 0, 0 ]
[ 'hi!', 'hi' ]
Uint8Array(4) [ 1, 7, 7, 7 ]
words[0] += '!' builds a new string and stores it in slot 0. grid[0][0] = 1 does not touch slot 0 of grid at all: it follows the reference in slot 0 to the inner array and changes that, and every other slot points at the same inner array. Typed arrays can only hold numbers (or BigInts, for BigInt64Array and BigUint64Array), which are primitives too, so they cannot have this bug.
What to do
Create one element per slot with a callback. Array.from calls its mapping function once per index, so each call returns a new array or object:
const grid = Array.from({ length: 3 }, () => new Array(3).fill(0))
grid[0][0] = 1
console.log(grid)
const buckets = Array.from({ length: 3 }, () => [])
buckets[0].push('a')
console.log(buckets)
const viaMap = new Array(3).fill().map(() => [])
console.log(viaMap[0] === viaMap[1])
const skipped = new Array(3).map(() => [])
console.log(skipped)
[ [ 1, 0, 0 ], [ 0, 0, 0 ], [ 0, 0, 0 ] ]
[ [ 'a' ], [], [] ]
false
[ <3 empty items> ]
The inner new Array(3).fill(0) is fine: it fills with a number. The bug lives at every level where the value being filled is an object; in a 2-D grid that is only the outer one.
new Array(3).fill().map(() => []) also works, because fill() with no argument writes undefined into every slot and gives map something to visit. The last block shows why the empty fill() is needed: new Array(3) has holes, not values, and map skips holes without calling the callback, so you get three holes back. That behavior has its own post. Array.from avoids the question entirely.
Watch for the same shape outside fill. Any time one object is created once and then handed out to several places, you get the same sharing. Building a lookup object from a list of keys is the usual one:
const keys = ['admins', 'editors', 'viewers']
const empty = []
const shared = Object.fromEntries(keys.map((k) => [k, empty]))
shared.admins.push('ada')
console.log(shared)
const separate = Object.fromEntries(keys.map((k) => [k, []]))
separate.admins.push('ada')
console.log(separate)
const counts = new Map(keys.map((k) => [k, { n: 0 }]))
counts.get('admins').n++
console.log(counts)
{ admins: [ 'ada' ], editors: [ 'ada' ], viewers: [ 'ada' ] }
{ admins: [ 'ada' ], editors: [], viewers: [] }
Map(3) {
'admins' => { n: 1 },
'editors' => { n: 0 },
'viewers' => { n: 0 }
}
The rule is the same as with fill: write the [] or {} literal inside the callback, so it runs once per key, not once in total. A const DEFAULT = [] hoisted out of the callback “to avoid allocating” brings the bug back.
Caveats
- On
new Array(n),fill(value, start)leaves the earlier slots as holes (andfill(value, start, end)leaves the later ones too).filldoes not create the holes, it just does not touch slots outside its range:new Array(3).fill(0, 1)gives[ <1 empty item>, 0, 0 ], somapandforEachwill skip index 0, while[1, 2, 3].fill(0, 1)gives[ 1, 0, 0 ]. Fill the whole array first if the range does not cover it. - Sharing is sometimes the point. Filling with one deeply frozen or never-mutated object, such as a shared default config you never mutate, is safe and saves allocations. The bug is only a bug when something writes through a slot.
- Python has the identical trap.
[[None] * 2] * 3builds three references to one list, and the Python FAQ’s explanation reads the same as MDN’s: “replicating a list with*doesn’t create copies, it only creates references to the existing objects.” The idiomatic fix is a list comprehension, the counterpart of theArray.fromcallback. - Every block above was run on Node v26.2.0.
fillandArray.fromare defined by the language spec, so browsers behave the same; the exactconsole.logformatting of nested arrays, holes, typed arrays and Maps differs between Node and browser devtools.