Knowledge Base/guides/JavaScript reduce Accumulator Patterns for Real Data

JavaScript reduce Accumulator Patterns for Real Data

JavaScript reduce accumulator patterns are easier to choose when you start with the final data shape. Do not begin by asking, "How do I write this callback?" Begin by asking, "What should the final answer look like for an empty list, one item, and many items?"

That question turns reduce from a clever trick into a data-shaping tool.

Table of contents

Pick the accumulator shape first

Most useful reducers fit one of these shapes:

NeedAccumulator shapeInitial value
Add numbersNumber0
Multiply numbersNumber1
Count by a propertyObject{}
Group items by a propertyObject of arrays{}
Build totals by groupObject of numbers{}
Compute an averageSummary object{ total: 0, count: 0 }
Look up records by IDObject or Map{} or new Map()
Remove duplicatesObject with seen and items{ seen: new Set(), items: [] }

The initial value is not decoration. It defines the accumulator's type and the empty-list result.

Pattern 1: running total

Use a number accumulator when each item contributes one numeric amount.

const cart = [
  { sku: "course", price: 39, quantity: 1 },
  { sku: "workbook", price: 12, quantity: 2 },
  { sku: "practice-pack", price: 19, quantity: 1 },
];

const total = cart.reduce((total, item) => {
  return total + item.price * item.quantity;
}, 0);

console.log(total); // 82

This pattern is good for:

  • Cart totals
  • Total minutes
  • Revenue summaries
  • Combined scores
  • Remaining balances

The accumulator name should describe the number: total, totalMinutes, totalRevenue, balance, or score.

Pattern 2: count by key

Use an object accumulator when you need to count how often each key appears.

const tickets = [
  { id: 1, status: "open" },
  { id: 2, status: "closed" },
  { id: 3, status: "open" },
  { id: 4, status: "waiting" },
];

const countsByStatus = tickets.reduce((countsByStatus, ticket) => {
  const status = ticket.status;
  countsByStatus[status] = (countsByStatus[status] ?? 0) + 1;
  return countsByStatus;
}, {});

console.log(countsByStatus);
// { open: 2, closed: 1, waiting: 1 }

The important line is:

countsByStatus[status] = (countsByStatus[status] ?? 0) + 1;

That means: if this status already has a count, use it. Otherwise start from 0. Then add one.

This handles statuses you did not know ahead of time. Real data loves showing up with a new status five minutes before lunch.

Pattern 3: group items by key

Counting tells you how many. Grouping keeps the actual records.

const events = [
  { type: "click", label: "pricing" },
  { type: "view", label: "home" },
  { type: "click", label: "signup" },
];

const eventsByType = events.reduce((eventsByType, event) => {
  const type = event.type;
  eventsByType[type] ??= [];
  eventsByType[type].push(event);
  return eventsByType;
}, {});

console.log(eventsByType);
// {
//   click: [
//     { type: "click", label: "pricing" },
//     { type: "click", label: "signup" }
//   ],
//   view: [{ type: "view", label: "home" }]
// }

This pattern is useful when the next step needs each group:

const clickedLabels = eventsByType.click.map((event) => event.label);

If the key might be missing, decide on a fallback:

const eventsByType = events.reduce((eventsByType, event) => {
  const type = event.type ?? "unknown";
  eventsByType[type] ??= [];
  eventsByType[type].push(event);
  return eventsByType;
}, {});

That decision should be explicit. Silent undefined buckets are how reporting bugs get their little apartment in your codebase.

Pattern 4: group totals by key

Sometimes you do not need every record in the group. You only need the total for each group.

const transactions = [
  { category: "books", amount: 18 },
  { category: "tools", amount: 35 },
  { category: "books", amount: 12 },
  { category: "tools", amount: 5, voided: true },
];

const totalsByCategory = transactions.reduce((totalsByCategory, transaction) => {
  if (transaction.voided) return totalsByCategory;

  const category = transaction.category;
  totalsByCategory[category] = (totalsByCategory[category] ?? 0) + transaction.amount;
  return totalsByCategory;
}, {});

console.log(totalsByCategory);
// { books: 30, tools: 35 }

Notice the early return:

if (transaction.voided) return totalsByCategory;

The accumulator is still returned unchanged. Every reduce callback must return the next accumulator, even when the current item should be ignored.

Pattern 5: summary object for averages

An average needs two pieces of state: total and count. That means the accumulator should hold both.

const attempts = [
  { score: 80, completed: true },
  { score: 50, completed: false },
  { score: 100, completed: true },
];

const summary = attempts.reduce(
  (summary, attempt) => {
    if (!attempt.completed) return summary;

    return {
      total: summary.total + attempt.score,
      count: summary.count + 1,
    };
  },
  { total: 0, count: 0 },
);

const average = summary.count === 0 ? null : summary.total / summary.count;

console.log(average); // 90

This pattern keeps the reduction honest. The reducer does not pretend it can compute an average from one number alone.

You can mutate the summary accumulator instead if you prefer a single internal object:

const summary = attempts.reduce(
  (summary, attempt) => {
    if (!attempt.completed) return summary;

    summary.total += attempt.score;
    summary.count += 1;
    return summary;
  },
  { total: 0, count: 0 },
);

Both versions can be reasonable. The important part is that the input attempt objects are not changed.

Pattern 6: index by id

Use an object accumulator when you want fast lookup by a stable string key.

const users = [
  { id: "u1", name: "Ada" },
  { id: "u2", name: "Grace" },
  { id: "u3", name: "Katherine" },
];

const usersById = users.reduce((usersById, user) => {
  usersById[user.id] = user;
  return usersById;
}, {});

console.log(usersById.u2);
// { id: "u2", name: "Grace" }

Use Map when keys are not plain strings, when insertion order matters to later logic, or when you want the lookup API to make the data structure obvious:

const usersById = users.reduce((usersById, user) => {
  usersById.set(user.id, user);
  return usersById;
}, new Map());

console.log(usersById.get("u2"));
// { id: "u2", name: "Grace" }

For plain JSON-like data, an object is often enough. For richer key behavior, reach for Map.

Pattern 7: dedupe while keeping order

Deduping needs two things:

  • A way to remember what you have seen.
  • A list of items that survived.

That means the accumulator can be an object with a Set and an array.

const signups = [
  { email: "ada@example.com", plan: "pro" },
  { email: "grace@example.com", plan: "team" },
  { email: "ada@example.com", plan: "pro" },
];

const uniqueSignups = signups.reduce(
  (state, signup) => {
    if (state.seen.has(signup.email)) return state;

    state.seen.add(signup.email);
    state.items.push(signup);
    return state;
  },
  { seen: new Set(), items: [] },
).items;

console.log(uniqueSignups);
// [
//   { email: "ada@example.com", plan: "pro" },
//   { email: "grace@example.com", plan: "team" }
// ]

This keeps the first occurrence of each email. If your rule is "keep the latest record," write that rule directly. Do not let the word "dedupe" hide the business decision.

Mutation, copying, and the accumulator

Many reducers mutate the accumulator:

const countsByStatus = tickets.reduce((countsByStatus, ticket) => {
  countsByStatus[ticket.status] = (countsByStatus[ticket.status] ?? 0) + 1;
  return countsByStatus;
}, {});

That is usually fine because {} is created just for this reduction. You are not mutating the source tickets array or the ticket objects.

This version avoids mutating the accumulator:

const countsByStatus = tickets.reduce((countsByStatus, ticket) => {
  return {
    ...countsByStatus,
    [ticket.status]: (countsByStatus[ticket.status] ?? 0) + 1,
  };
}, {});

It is tidy, but it copies the accumulator object on every item. For a small list, nobody cares. For a large list, that extra copying can become the real cost of the function.

Use immutable returns when they clarify state changes or match the surrounding code style. Use accumulator mutation when you are building a fresh internal result and the copy would only add noise. In both cases, avoid mutating the input records unless the function name and caller contract explicitly allow it.

When not to use these patterns

Use map when every item becomes another item:

const labels = users.map((user) => `${user.id}: ${user.name}`);

Use filter when some items survive:

const activeUsers = users.filter((user) => user.active);

Use find when you want the first matching item:

const owner = users.find((user) => user.role === "owner");

Use for...of when control flow is the point:

let owner = null;

for (const user of users) {
  if (!user.active) continue;

  if (user.role === "owner") {
    owner = user;
    break;
  }
}

The How to Use reduce in JavaScript Without Getting Confused guide covers the mental model. This page is the pattern shelf. Reach for it when the final data shape is a total, lookup, group, summary, or deduped list.

Key Takeaways

  • Pick the accumulator shape before writing the callback.
  • The initial value should be the correct empty-list answer.
  • Object accumulators are useful for counts, groups, totals, and ID lookups.
  • Summary accumulators are the right shape when the final answer needs more than one piece of state.
  • Mutating a fresh accumulator is different from mutating the input data. Keep that boundary clear.
Share this article:
javascriptarraysfunctionaldata