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
- Pattern 1: running total
- Pattern 2: count by key
- Pattern 3: group items by key
- Pattern 4: group totals by key
- Pattern 5: summary object for averages
- Pattern 6: index by id
- Pattern 7: dedupe while keeping order
- Mutation, copying, and the accumulator
- When not to use these patterns
- Key Takeaways
Pick the accumulator shape first
Most useful reducers fit one of these shapes:
| Need | Accumulator shape | Initial value |
|---|---|---|
| Add numbers | Number | 0 |
| Multiply numbers | Number | 1 |
| Count by a property | Object | {} |
| Group items by a property | Object of arrays | {} |
| Build totals by group | Object of numbers | {} |
| Compute an average | Summary object | { total: 0, count: 0 } |
| Look up records by ID | Object or Map | {} or new Map() |
| Remove duplicates | Object 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.