Frontend applications frequently combine paged responses, grouped options, chart series, or nested lists into one array. The expression reduce((acc, chunk) => [...acc, ...chunk], []) looks concise, but every iteration constructs another array and re-copies the values accumulated so far. As the number of chunks grows, data preparation can become a larger interaction cost than rendering the visible rows.

This guide focuses on JavaScript array flattening with flatMap, flat(1), and controlled push accumulation. It explains why repeated accumulator spreading has quadratic copy growth, how to preserve ordering and shape contracts during a refactor, and how to verify the behavior without presenting an unstable timing benchmark as a universal result. Useful KPIs are long-task duration, input-to-update latency, allocation pressure, and output correctness—not source-code brevity alone.

Why does reduce with spread amplify array-flattening work?

Each [...accumulator, ...chunk] expression creates a new array, expands the complete accumulator again, and then adds the current chunk. With n single-item chunks, previously accumulated items are copied 0 through n-1 times, so the repeated-copy count grows quadratically.

The issue is not reduce by itself. The issue is rebuilding a growing accumulator inside every reducer call. The first iteration copies no old values. The second copies one. The third copies two. The final iteration copies n minus one. Counting only those repeated old-accumulator copies gives n * (n - 1) / 2. This is a structural property of that implementation, not a promise about a particular number of milliseconds in every engine.

Every correct flattening algorithm must visit the output values at least once. The avoidable work is revisiting the entire accumulated prefix for each new chunk. A tiny list may never justify a change, but autocomplete dictionaries, reporting series, import previews, and grouped API responses can run this transformation repeatedly on an interaction path. In those cases, inspect data construction before assuming the DOM is the first bottleneck.

MDN describes array spread syntax as expanding iterable values into an array literal. That behavior is useful for a shallow copy or for joining a small, fixed number of arrays. It does not make repeated spreading of a growing accumulator free. Code review should therefore ask how many times the spread executes and how large the left-hand array becomes, rather than judging the expression only by its readability in isolation.

The conclusion should remain bounded. This guide does not claim that flatMap wins every microbenchmark or that every spread is harmful. It identifies a specific pattern whose copy count can be derived from the source structure and then verified with deterministic fixtures.

When should you choose flatMap, flat, or controlled push accumulation?

Use flatMap when each source item is transformed into zero or more values and the result needs one-level flattening. Use flat(1) when the data is already a two-dimensional array. Use a fresh local accumulator with push when one loop must combine validation, filtering, or deduplication.

The following excerpts assume groups is already defined, every group is a valid object, and values is an ordinary dense array or absent. Under that contract, the two versions preserve the same order and values:

const values = groups.flatMap((group) => group.values ?? []);

const valuesWithPush = groups.reduce((result, group) => {
  result.push(...(group.values ?? []));
  return result;
}, []);

Both versions create a new outer result rather than inserting values into the source groups array. The push version mutates only the fresh result that is created inside this operation and has not been shared. That is materially different from mutating React props, Redux state, a cache entry, or an array received from a caller.

flatMap communicates a useful invariant: map each source element, then flatten exactly one level. MDN documents that it returns a new array and is equivalent in intent to a mapping followed by one-level flattening, while allowing an implementation to avoid an explicit intermediate array. It is usually the clearer choice when the callback already returns arrays and no additional stateful policy is required.

A controlled push loop is appropriate when the transformation needs explicit shape checks or complex conditions. If one chunk can be extremely large, avoid assuming that result.push(...chunk) has no practical argument-count limit. A nested for...of loop can append values one at a time while preserving one-pass accumulation:

const result = [];
for (const group of groups) {
  if (group.values == null) continue;
  if (!Array.isArray(group.values)) throw new TypeError("Expected an array");
  for (const value of group.values) result.push(value);
}

Choose flat(1) when no mapping is needed: chunks.flat(1). Do not reach for flat(Infinity) unless arbitrary nesting is part of the documented input contract. Recursive flattening can hide an upstream shape defect and produce a result whose element type is harder to reason about.

Which correctness contracts must remain stable during the refactor?

Lock down output order, missing-value policy, flattening depth, item identity, and error behavior before replacing the implementation. Matching only the final length is insufficient because reordered values, silently ignored malformed chunks, or shared object references can still corrupt the interface.

Order is the first contract. flatMap processes the source array in its iteration order and appends the callback's output in order. If the old reducer also sorted, deduplicated, grouped, or inserted markers, a syntax-only replacement is not equivalent. Write a fixed input and the complete expected output before changing the implementation.

Missing data is the second contract. group.values ?? [] treats only null and undefined as absent. It does not replace numeric zero or an empty string with the fallback. However, both are malformed inputs when this field is required to contain an array; preserving a value does not make it valid for flattening. However, if values is expected to be an array, a stronger boundary is Array.isArray(group.values). A malformed response should often become an observable read failure rather than being silently converted into an empty result that looks like valid no-data behavior.

Flattening depth is the third contract. flatMap flattens one level. For [[1, [2]], [3]], the result is [1, [2], 3], not [1, 2, 3]. The local verification includes this exact assertion because developers often read the word “flat” and infer recursive behavior that the method does not provide.

Reference behavior is the fourth contract. Spread syntax, flat, and flatMap create a new outer array, but they do not deep-clone objects. If a source chunk contains an object, the result normally contains the same object reference. A later mutation can therefore affect all places that hold that object. If isolation is required, project each value deliberately, for example { ...item }, and test the fields that must be detached. Object spread is also shallow: nested objects remain shared unless those specific fields are projected separately.

Sparse arrays are another edge case. flatMap skips source holes and omits holes in arrays returned by its callback. Array spread turns holes in an ordinary sparse array into undefined entries. Consequently, flatMap and spread-push are not generally interchangeable for sparse chunks. Normal JSON arrays do not contain true sparse holes, but manually adjusted lengths and deleted indexes can create them. If the source can be sparse, include that shape in the test suite rather than assuming a dense fixture represents every case.

How does this pattern interact with React filtering and rendering?

Measure data preparation separately from React element creation and DOM commit. If flattening and filtering already form a long task, limiting visible rows cannot remove repeated copies performed before rendering begins. A memoization cache hit can skip the calculation; a cache miss still pays the original implementation's copy cost.

A large typeahead commonly pays three different costs: combining the source lists, filtering them for the current query, and rendering a bounded result set. A maxResults property can reduce rendered options without reducing the cost of building the complete candidate array. A minimum query length can delay filtering, but it does not help if every keystroke first rebuilds a large flattened list through repeated spread accumulation.

A clearer data flow normalizes the source once when the source batches change, reuses the normalized list while the user types, and applies the display limit only after matching. If the source is already normalized by a state layer, do not rebuild it in every component render. If it must be derived in the component, memoization helps only when its dependencies keep stable references.

This distinction prevents a second common mistake: adding useMemo while the parent creates a new groups array on every render. The dependency changes every time, so the memoized calculation runs every time. Stabilize the ownership and update boundary first. Memoization is a reuse policy, not a repair for unstable input identity.

The same principle applies outside React. A worker, Node transformation, browser event handler, or state selector can all repeat this copy pattern. The technical classification is frontend JavaScript performance and correctness, but the underlying question is independent of a particular UI framework: how often is a growing output reconstructed, and what contract governs the result?

Track data-preparation duration with the same fixture, browser long tasks, input-to-update latency, and allocation trends. Those are engineering KPIs, not guaranteed outcomes. This article does not provide a universal threshold or claim a percentage improvement because engines, devices, data shape, and surrounding work differ. First remove the deterministically repeated copies, then profile the target application.

Verification and reproduction with Node.js 26.7.0

This offline lab verifies equivalent order and values, the repeated-copy formula for spread accumulation, one-level flatMap behavior, and source-array preservation. The scheduler executed it with fixed data; it performs no network request and reads no application data.

  1. In an empty test directory, save the complete file below as array_flatten_contract_20261015.js. The observed runtime is Node.js 26.7.0; readers using another modern Node release should record the actual version.
  2. Run node array_flatten_contract_20261015.js. Pass requires exit code zero, four named PASS lines, and the final SUMMARY 4/4 line.
  3. Inspect the 100-singleton case. It must report 4,950 repeated old-accumulator element copies, which equals 100 * 99 / 2; a wrong count or output length must fail the process.
  4. As a negative control, change the one-level expected result from [1, [2], 3] to [1, 2, 3]. Rerun the file and require an assertion failure. This proves the test can detect an incorrect assumption about recursive flattening.
"use strict";

const assert = require("node:assert/strict");

function flattenWithSpread(chunks) {
  let copiedAccumulatorElements = 0;
  const result = chunks.reduce((accumulator, chunk) => {
    copiedAccumulatorElements += accumulator.length;
    return [...accumulator, ...chunk];
  }, []);
  return { result, copiedAccumulatorElements };
}

function flattenWithPush(chunks) {
  return chunks.reduce((accumulator, chunk) => {
    accumulator.push(...chunk);
    return accumulator;
  }, []);
}

function verifyEquivalentResults() {
  const chunks = [["a", "b", "c"], ["d", "e"], ["f", "g", "h", "i"], ["j"]];
  const spread = flattenWithSpread(chunks);
  const flatMapResult = chunks.flatMap((chunk) => chunk);
  const pushResult = flattenWithPush(chunks);

  assert.deepEqual(spread.result, flatMapResult);
  assert.deepEqual(pushResult, flatMapResult);
  assert.equal(spread.copiedAccumulatorElements, 17);
  console.log("PASS equivalent order and values; spread recopied 17 accumulated elements");
}

function verifyQuadraticCopyGrowth() {
  const chunkCount = 100;
  const chunks = Array.from({ length: chunkCount }, (_, index) => [index]);
  const spread = flattenWithSpread(chunks);
  const expectedRepeatedCopies = (chunkCount * (chunkCount - 1)) / 2;

  assert.equal(spread.copiedAccumulatorElements, expectedRepeatedCopies);
  assert.equal(spread.result.length, chunkCount);
  console.log(`PASS ${chunkCount} singleton chunks require ${expectedRepeatedCopies} repeated accumulator-element copies`);
}

function verifyOneLevelFlattening() {
  const nested = [[1, [2]], [3]];
  const result = nested.flatMap((chunk) => chunk);

  assert.deepEqual(result, [1, [2], 3]);
  console.log("PASS flatMap flattens one level and preserves deeper nested arrays");
}

function verifyInputIsNotMutated() {
  const chunks = [[1, 2], [3]];
  const snapshot = JSON.stringify(chunks);
  const result = chunks.flatMap((chunk) => chunk);

  assert.equal(JSON.stringify(chunks), snapshot);
  assert.notEqual(result, chunks[0]);
  console.log("PASS flatMap returns a separate result without mutating the source chunks");
}

function main() {
  verifyEquivalentResults();
  verifyQuadraticCopyGrowth();
  verifyOneLevelFlattening();
  verifyInputIsNotMutated();
  console.log(`SUMMARY 4/4 checks passed on Node ${process.versions.node}`);
}

try {
  main();
} catch (error) {
  console.error("FAIL", error);
  process.exitCode = 1;
}
PASS equivalent order and values; spread recopied 17 accumulated elements
PASS 100 singleton chunks require 4950 repeated accumulator-element copies
PASS flatMap flattens one level and preserves deeper nested arrays
PASS flatMap returns a separate result without mutating the source chunks
SUMMARY 4/4 checks passed on Node 26.7.0

The real execution printed four PASS lines. It reported 17 repeated accumulated-element copies for the small fixture, 4,950 for 100 singleton chunks, preservation of the deeper [2] array, and no mutation of the source chunks. The final line was SUMMARY 4/4 checks passed on Node 26.7.0, and the process exited with code zero.

The first fixture also proves that spread reduction, flatMap, and controlled push produced the same ordered values. That does not prove equal memory use, browser responsiveness, or component behavior. The repeated-copy counter models old accumulator elements that the source expression must expand again; it is deliberately not a wall-clock benchmark.

Readers can extend the reproduction in an isolated frontend test project with a browser Performance trace, a stable generated fixture, and a component interaction test. Those are reader-reproducible steps; not executed in the scheduler environment. Keep the fixture independent of real users, accounts, and application endpoints.

What should the review checklist and acceptance criteria contain?

Review the data contract before accepting a shorter expression. A safe change proves output order and shape, distinguishes missing data from malformed data, limits flattening depth, documents object-reference behavior, and measures the actual interaction path after deterministic checks pass.

Use this checklist:

  • The source is an array of documented chunks or groups.
  • The complete output order is asserted, not only its length.
  • null, undefined, empty arrays, and malformed non-array values have separate policies.
  • One-level flattening is intentional and covered by a nested fixture.
  • The new outer array does not imply a deep copy of contained objects.
  • Local accumulator mutation cannot escape before the function returns.
  • Memoization dependencies have stable ownership rather than being recreated on every render.
  • Display limits are not mistaken for reduced data-preparation work.
  • Profiling uses a fixed fixture and the target browser or runtime.
  • No one-run timing number is presented as a cross-environment performance guarantee.

The North Star Metric for this change is not “lines removed.” It is predictable transformation work while preserving the exact data contract. Supporting measures include long-task incidence on the relevant interaction, input-to-visible-update latency, allocation trends, and regression-test coverage for order and shape.

The durable rule is narrow: reduce is not inherently slow, and spread syntax is not inherently wrong. The risky pattern is repeatedly spreading an accumulator whose size grows on every iteration. Use flatMap to express mapping plus one-level flattening, use flat(1) for an existing two-dimensional array, or use a fresh local accumulator when explicit control is required. Then verify order, shape, references, and copy growth before treating the refactor as complete.

Official references