Skip to content

Instantly share code, notes, and snippets.

@antishow
Last active May 18, 2018 14:27
Show Gist options
  • Save antishow/cebc725c54334ac1a86c93fb69680840 to your computer and use it in GitHub Desktop.
Save antishow/cebc725c54334ac1a86c93fb69680840 to your computer and use it in GitHub Desktop.

REDUCING Code Complexity

Reducers are an interesting and powerful tool in Javascript, but it can be difficult to come up with good examples that aren’t overly trivial or contrived to the point of uselessness. We use a LOT of Reducers in our boilerplate Resource Grid component, and Redux in general is pretty much built around the use of Reducers. They can be used for a lot more though! Like map and forEach, Array.reduce is a great way to process an entire set of data at once.

Wait, what’s a Reducer again?

A reducer is a function that works with Array.reduce to transform an array into some other value. This is very similar to Array.map, in that it’s used to process an array with a function, the difference is that instead of transforming from one Array to another, we’re going from an Array to some value.

When calling reduce on an array, you’ll pass in two things: your reducer and the start value. On the first call to the reducer, the function will be passed the start value and the 0th item in the array. Then, the result of that will be used as the start value alongside the 1st item in the array the next time the reducer is called. When you finally reach the end of the array, the return value from your reducer is the final result. This sounds complicated when written out, but in practice it’s pretty simple.

Here’s a very simple example: using a reducer to get the sum of all of the items in the array.

The Simplest (and most useless) Example: sum()

First let’s do it the old-school way, with a loop

let total = 0;
let arr = [1, 2, 3];
for (let i = 0; i < arr.length; i++) {
    let n = arr[i];
    console.log('SUM(%d, %d) = %d', total, n, total + n);
    total = total + n;
}
console.log(total);
//SUM(0, 1) = 1
//SUM(1, 2) = 3
//SUM(3, 3) = 6
//6

Now we’ll do the exact same thing with a Reducer function, sum(). Note that this is way more verbose than it needs to be so we can watch it work. In practice this would simply be: const sum = (a, b) => a+b;

const sum = (a, b) => {
    console.log('SUM(%d, %d) = %d', a, b, a + b);
    return a + b;
};

console.log([1, 2, 3].reduce(sum, 0));
//SUM(0, 1) = 1
//SUM(1, 2) = 3
//SUM(3, 3) = 6
//6

By using reduce() instead of a for loop, we are able to remove the i and n variables and an entire scope from what we were doing. In this super-minimal example it doesn’t seem like much benefit, but simpler code is easier to read, reason about, and most importantly, debug!

Data Histograms

Now for an example you might actually use someday, transforming an object into a histogram. Consider the following blob of JSON data:

const BowlingBalls = [
    { owner: 'Fred', color: 'Red', weight: 14 },
    { owner: 'Wilma', color: 'Red', weight: 8 },
    { owner: 'Fred', color: 'Blue', weight: 12 },
    { owner: 'Barney', color: 'Brown', weight: 13 },
    { owner: 'Fred', color: 'Green', weight: 12 }
];

Let’s analyze the data and generate a list of the owners and how many bowling balls they own. Again, we could use a traditional for loop:

let ownerHistogram = {};
for (let i = 0; i < BowlingBalls.length; i++) {
    let ball = BowlingBalls[i];

    if (ball.owner in ownerHistogram) {
        ownerHistogram[ball.owner]++;
    } else {
        ownerHistogram[ball.owner] = 1;
    }
}

But here it is with a reducer:

let ownerHistogram = BowlingBalls.reduce((oh, ball) => {
    if (ball.owner in oh) {
        oh[ball.owner]++;
    } else {
        oh[ball.owner] = 1;
    }

    return oh;
}, {});

Now that we’re doing something more complicated than a simple sum it’s not immediately clear how this is better than just using the loop. Certainly it isn’t less code. Since this is Javascript, we can write code to generate a reducer for us!

const histogramByKey = key => (ret, obj) => {
    if (obj[key] in ret) {
        ret[obj[key]]++;
    } else {
        ret[obj[key]] = 1;
    }

    return ret;
};

BowlingBalls.reduce(histogramByKey('owner'), {});
// { "Fred": 3, "Wilma": 1, "Barney": 1 }

BowlingBalls.reduce(histogramByKey('color'), {});
// { "Red": 2, "Blue": 1, "Brown": 1, "Green": 1 }

Fancy Filtering

Finally, let’s take a closer look at the filter function I looked at in my last e-mail on Promises. For this example let’s say the data looks like this:

const ContentExchangePages = [
    { id: '1', title: 'Some Page', tags: ['type:foo', 'topic:blah'] },
    { id: '2', title: 'A Page', tags: ['type:bar'] },
    { id: '3', title: 'Blah blah blah', tags: ['topic:blah'] },
    { id: '4', title: 'Some Page', tags: ['type:foo', 'topic:derp'] }
];

The tags are just a simple list of strings, but they all have some kind of prefix that is used to group them into (essentially) taxonomies. On the front-end, the filter is just a straight-up list of tags that have been checked, so let’s say it looks like this:

const SelectedTags = ['type:foo', 'topic:blah'];

At first the code for this was very simple. We just wanted to grab any page that had at least one of the selected tags. Literally a single line thanks to lodash’s intersection:

function filterContentExchangePages(pages, filterState) {
    let ret = pages;

    if (filterState.length > 0) {
        ret = pages.filter(p => intersection(filterState, p.tags));
    }

    return Promise.resolve(ret);
}
/* [
    { id: "1", title: "Some Page", tags: ["type:foo", "topic:blah"] },
    { id: "3", title: "Blah blah blah", tags: ["topic:blah"] },
    { id: "4", title: "Some Page", tags: ["type:foo", "topic:derp"] }
] */

But then we were asked to change the filter so that when tags in multiple categories are selected, then we need at least one tag in every selected category to match. So in the previous example we’d now filter out 3 because it doesn't have type:foo and 4 because it doesn’t have topic:blah.

First I wrote a function that uses a reducer to take my simple list of tags and group them by their prefix.

// Convert array from ['a:1', 'a:2', 'b:1', 'c:1', 'c:2', 'c:3']
// to [['a:1', 'a:2'], ['b:1'], ['c:1', 'c:2', 'c:3']]
function groupArrayByPrefix(arr, prefixDelimiter) {
    if (!Array.isArray(arr)) {
        return null;
    }

    return Object.values(
        arr.reduce((ret, val) => {
            let spl = val.split(prefixDelimiter);
            let prefix = spl.shift();

            if (!ret[prefix]) {
                ret[prefix] = [];
            }

            ret[prefix].push(val);

            return ret;
        }, {})
    );
}

With my tags grouped, I now pass my pages through a filter function that will check each page against the filters. Nothing interesting here really, the reducer stuff happens inside the filter function.

function filterContentExchangePages(pages, filterState) {
    let ret = pages;

    if (filterState.length > 0) {
        let tagGroups = groupArrayByPrefix(filterState, ':');

        ret = pages.filter(page => pageMatchesFilter(page, tagGroups));
    }

    return Promise.resolve(ret);
}

Inside the filter, I use a reducer to iterate over the array and make sure that the page matches ALL the groups. This is pretty much exactly the same as the Sum reducer, but using the && operator instead of +. Because && returns true when both sides of the operator are true, this reducer will only return true when ALL of them match!

function pageMatchesFilter(page, filter) {
    return filter.reduce(
        (pass, tags) => pass && intersection(tags, page.tags).length > 0,
        true
    );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment