cover-image

JavaScript's Array Reduce: Mutable vs. Immutable Accumulators

#javascript
#typescript
#array
#reduce
#functional
#immer
#lodash

Andrew Vo-Nguyen

July 11, 2023

6 min read

JavaScript's reduce array method can be incredibly useful in data transformations and building data structures, while at the same time iterating through an existing data structure. It is great for building arrays, objects, strings, or keeping an integer count. The thing that separates .reduce() from other array methods is that each iteration has access to an accumulator from the previous iteration. This allows you to keep building the final output.

JavaScript is agnostic about the programming paradigms you choose to implement. It allows mutation of objects and arrays, even if they are declared with the const keyword. This can be disturbing for functional programmers, but convenient for others.

For this experiment, I wanted to explore four different ways of interacting with the accumulator. The first way is to mutate the accumulator directly and return it for the next iteration. The second way is to create a shallow clone of it and return a new copy of it for the next iteration. The third way is to use a third-party library called immer to artificially mutate the accumulator, and lastly, use lodash's deep clone to clone the accumulator on each iteration. I mainly want to test convenience, reliability, and performance.

To set up the performance testing, I have written a custom benchmark function:

1// implementation
2function benchmark(fn: (...args: any[]) => any, iterations: number, ...args: any[]): number {
3  const start = performance.now();
4  for (let i = 0; i < iterations; i++) {
5    fn(...args);
6  }
7  return performance.now() - start;
8}
9
10// usage
11console.log("performance in ms:", benchmark(anyFn, 1000));

Here is some more setup code to emulate an API call:

1type ApiResult = {
2  status: "fulfilled" | "rejected";
3  data: {
4    id: string;
5    name: string;
6    list: string[];
7  };
8};
9
10async function mockApiCall(): Promise<ApiResult> {
11  return {
12    status: "fulfilled",
13    data: {
14      id: "123",
15      name: "John",
16      list: ["a", "b", "c"],
17    },
18  };
19}

Experiment 1: Mutate directly

This implementation pushes successful API calls onto a list directly and returns it. All existing arrays and objects are subject to direct mutation. This is the most convenient implementation.

1  const mutable = (results: PromiseSettledResult<ApiResult>[]): ApiResult[] =>
2    results.reduce<ApiResult[]>((list, result) => {
3      if (result.status === "fulfilled") {
4        list.push(result.value);
5      }
6      return list;
7    }, []);

Experiment 2: Shallow clone

This implementation returns a shallow copy of the list. Nested objects or arrays are not cloned and could be subject to mutation. The shallow clone only returns a new reference of the root array.

1  const immutable_shallow = (results: PromiseSettledResult<ApiResult>[]): ApiResult[] =>
2    results.reduce<ApiResult[]>((list, result) => {
3      if (result.status === "fulfilled") {
4        return [...list, result.value];
5      }
6      return [...list];
7    }, []);

Experiment 3: Immer

Immer's produce function creates a dradt that can be mutated directly. At the end of the function, the final draft is returned to the reducer as a new deep copy. Immer uses JavaScript proxies to make this happen. There is a bit more code involved here, as an additional .produce() function is called. However, everything that happens inside the callback (2nd argument of produce) is completely safe from mutating the input list.

1  const immutable_immer = (results: PromiseSettledResult<ApiResult>[]): ApiResult[] =>
2    results.reduce<ApiResult[]>(
3      (list, result) =>
4        produce(list, (draft) => {
5          if (result.status === "fulfilled") {
6            draft.push(result.value);
7          }
8        }),
9      []
10    );

Experiment 4: Lodash cloneDeep

This implementation recursively clones all nested properties of the array. This deep clone can then be mutated and returned back to the reducer. It is a very safe approach but you have to inconventiently introduce a library.

1  const immutable_clone_deep = (results: PromiseSettledResult<ApiResult>[]): ApiResult[] =>
2    results.reduce<ApiResult[]>((list, result) => {
3      const newList = cloneDeep(list);
4      if (result.status === "fulfilled") {
5        newList.push(result.value);
6      }
7      return newList;
8    }, []);

Running the Experiment

1async function run() {
2  const promises: Array<Promise<ApiResult>> = [];
3  // simulate 5 artificial api calls
4  for (let i = 0; i < 5; i++) {
5    promises.push(mockApiCall());
6  }
7  const results = await Promise.allSettled(promises);
8  const iterations = 1000000;
9  console.log("mutable", benchmark(mutable, iterations, results));
10  console.log("immutable_shallow", benchmark(immutable_shallow, iterations, results));
11  console.log("immutable_immer", benchmark(immutable_immer, iterations, results));
12  console.log("immutable_deep_clone", benchmark(immutable_deep_clone, iterations, results));
13}

Each reduce function is run one million times. The benchmark function measures the start end end times and return the milliseconds between. This 5 times for consistency and to make sure there isn't any outlier or edge cases.

Run #1

1mutable 29.527875006198883
2immutable_shallow 130.11991602182388
3immutable_immer 14058.85670799017
4immutable_clone_deep 6958.972791016102

Run #2

1mutable 34.25262504816055
2immutable_shallow 152.47458297014236
3immutable_immer 14231.972290992737
4immutable_clone_deep 7050.699208974838

Run #3

1mutable 31.86175000667572
2immutable_shallow 136.76608395576477
3immutable_immer 13961.054750025272
4immutable_clone_deep 6677.063459038734

Run #4

1mutable 30.76087498664856
2immutable_shallow 138.12220799922943
3immutable_immer 14013.79820895195
4immutable_clone_deep 6667.2489579916

Run #5

1mutable 29.849125027656555
2immutable_shallow 130.21537494659424
3immutable_immer 14323.82945895195
4immutable_clone_deep 6703.130209028721

Each run exhibited similar, consistent results. With very little variance, we can safely take run #5 to analyze.

The direct mutation implementation was by far the quickest to iterate one million times, doing it in just 29ms, compared to the immer implementation, which took 14.3 seconds. The shallow implementation is about 4x times slower than the direct mutation and about 4x faster than the lodash clone deep implementation.

I can imagine the immer and deep clone implementations could exceed even further depending on the levels of nesting. In this case, we only had a fairly flat object with an array with only 3 properties. The shallow clone does not do too badly, and I suspect the overhead comes from calling the new Array constructor when we use the spread operator to create a new array. Don't forget, on every iteration a new array is created in memory and pretty much thrown away at the end. Whereas, the mutable implementation uses the same array reference in memory on each iteration.

So which is the preferred way to reduce an array?

If you are a strict functional programmer and want complete safety, then a deep clone implementation is for you. Using lodash's cloneDeep method seems to be twice as quick as immer's implementation.

If you care about performance and have hundreds of thousands of items to iterate over, I would recommend the shallow clone or mutating directly. Both can potentially be just as unsafe. You just need to make sure that the accumulator is only required for the scope of this reducer function only. Keep in mind that JavaScript is a single-threaded language and there is no risk of threads fighting over contention of the input array.

If your array size and complexity of the objects nested inside are fairly flat, then it doesn't matter which implementation you use. It comes down to what your team is most comfortable with, what is most convenient to reason with, and what gives you the most confidence in producing bug-free code.