Code Anatomy: Grouping array items into subarrays of a certain size
Something I need surprisingly often when doing front-end templating is the ability to take a one-dimensional list of data, like a product list, and output it in multiple dimensions.
Check out this intended HTML output:
<div class="row-wrapper">
<div class="row">
<div class="column left">
Product on the left of row 1
<img class="productThumbnail">
</div>
<div class="column right">
Product on the right of row 1
<img class="productThumbnail">
</div>
</div>
</div>
<div class="row-wrapper">
<div class="row">
<div class="column left">
Product on the left of row 2
<img class="productThumbnail">
</div>
<div class="column right">
Product on the right of row 2
<img class="productThumbnail">
</div>
</div>
</div>
<!-- ... etc... -->
And here's a one-dimensional list of products to feed into the template:
var products = [
{
name : "Phillips Screwdriver",
thumbnailURL : "/philscrewdriver.jpg"
},
{
name : "Duggan Hacksaw",
thumbnailURL : "/dugghacks.jpg"
},
{
name : "Presley Tenderizer",
thumbnailURL : "/tenderizer.jpg"
},
{
name : "Mavis Stapler",
thumbnailURL : "/mavstapler.jpg"
},
/* ... etc... */
]
So I have all the necessary data (imagine that list is 10s or 100s of objects long). But it's all at the same level, while I need to insert it in groups of 2, each group wrapped in <div class="row-wrapper"><div class="row">
.
Yes, if you had full control over the HTML output, this would be pretty easy:
products.forEach(function(product,idx){
if (idx % 2 == 0) { // modulo 2, e.g. even numbered indexes
/* create new <div class="row-wrapper"> and <div class="row"> inside it */
/* insert new left column into most recent row */
} else {
/* insert new right column into most recent row */
}
});
But not all template libraries will let you create the non-repeating blocks like that: they expect you to populate the repeated <div class="column">
from your data, not the outer framework.
Also, IMO it's better to split this into two steps —
- Map data to the correct nested layout
- Output in sync with the corrected data
— as opposed to nesting in and out as you output the data (all one step).
So, in sum, what we need is a function that can turn this one-dimensional array:
[1,2,3,4,5,6,7,8]
into this array-of-arrays, where the second nesting level are the groups:
[[1,2],[3,4],[5,6],[7,8],[9,10]]
and it should also support different group sizes, such as 3 for a 3-column layout:
[[1,2,3],[4,5,6],[7,8,9],[10]]
How to do this? Use our awesome friend Array#reduce
.
As I've mentioned before, reduce
is typically framed as the way to get a single (String, Number, maybe Date) value from an array. So most examples for reduce
do stuff like finding the longest string in an array of strings, or adding up an array of numbers to get a sum.
It's great for that kind of thing, but it can do more.
Despite its name, there's no reason reduce
can't return a more complex array than the original; it doesn't have to only “reduce” to simpler values. In general terms, reduce
is the only built-in JS function that can pass each item in an array to a user-defined function, also supplying the value last returned by the function for the previous item. (By contrast, forEach
passes each value to an user-defined function, but you don't know what the result was for the previous item in the array. Think of reduce
as stateful, while forEach
/map
/filter
/some
/every
are stateless across different items in the array.)
What we can do, then, is pass the one-dimensional array to reduce
, along with a new empty array as the starting point (the empty array is the first-level array).
Then, for each item, we say:
- if it's a multiple of the group size (using the
%
modulo operator)- create a new array containing the current item (this is the second-level array)
- add the second-level array onto the end of the first-level array
- if it's not a multiple of the group size
- add the current item onto the end of the last item in the outer array (this will be the most recently added second-level array)
- return the outer array, which will be passed into the next step along with the next item
The function toGroups
looks like this:
function toGroups(arr,maxGroupSize){
return arr.reduce(function(prev,next,idx){
if (idx % maxGroupSize == 0) {
prev.push([next]);
} else {
prev[prev.length-1].push(next);
}
return prev;
},[]);
};
And you can see it building up the result like this for when maxGroupSize = 2
:
[]
[[1]]
[[1,2]]
[[1,2],[3]]
[[1,2],[3,4]]
[[1,2],[3,4],[5]]
[[1,2],[3,4],[5,6]]
[[1,2],[3,4],[5,6],[7]]
[[1,2],[3,4],[5,6],[7,8]]
[[1,2],[3,4],[5,6],[7,8],[9]]
[[1,2],[3,4],[5,6],[7,8],[9,10]]
Once you have the properly nested version, it's easy to do stuff like this:
productRows.forEach(function(row,rowIdx){
/*
do something with each row
*/
row.forEach(function(product,productIdx){
/*
do something with each product in the row
- (productIdx) is the index within the group
- (productIdx + rowIdx * 2) is the index within the whole original list
*/
});
});
Enjoy!