with Map and Set in practice: selection sets and lookup maps
Most Vue tutorials work almost exclusively with arrays and plain objects, yet JavaScript ships two collection types with Map and Set that fit certain tasks noticeably better. Vue's reactivity system supports both fully, so reactive(new Map()) and reactive(new Set()) react to changes just as reliably as a reactive array does. Knowing when a Set-based selection in a multi-select UI or a Map-based lookup structure is the better choice, and which pitfalls lurk in destructuring, leads to noticeably more robust and faster code.
Table of Contents
- 1. Why Map and Set matter alongside arrays in Vue projects
- 2. reactive(new Map()) in detail: why it works
- 3. The destructuring pitfall: what breaks reactivity
- 4. Practical example: unique selection sets in a multi-select UI
- 5. Practical example: lookup maps for fast access to detail data
- 6. Performance comparison: Map lookup versus Array.find()
- 7. ref() versus reactive() for Map and Set: which makes more sense
- 8. Watching Map and Set changes: the deep option and its limits
- 9. Common mistakes and conclusion
- 10. Summary
- 11. FAQ
1. Why Map and Set matter alongside arrays in Vue projects
An array is the obvious choice for a list of items, but it quickly loses convenience once two requirements come up at the same time: unique values without manual duplicate checking, and fast access by key instead of by index or linear search. A Set guarantees by design that every value can only appear once, making it the natural data structure for selection states, where each item is either selected or not. A Map, in turn, maps keys to values while allowing arbitrary key types, not just strings the way a plain object does.
In practice both structures show up regularly: a Set for the IDs of selected rows in a table, a Map for looking up a product ID against a full product object to avoid repeated Array.find() calls. Vue treats both types as first-class citizens in its reactivity system, meaning set(), add(), delete() and clear() all refresh registered effects the same way push() or splice() do on a reactive array.
2. reactive(new Map()) in detail: why it works
Vue's reactive() wraps the given object in a proxy, and for Map and Set that proxy tracks not only read access to individual entries but also the mutating methods of the collection itself. Calling map.set(key, value) on a reactive map lets Vue internally recognize that the collection's state changed, and it notifies every effect that previously read from that map, for example through map.get(key), map.has(key) or map.size.
It is important that reactivity is tied to the reactive() proxy instance itself, not to the underlying original map. If the map is held inside a ref() instead of being wrapped directly with reactive(), .value must still be used inside script setup to reach the actual map instance before calling set(), get() or size. In the template, on the other hand, .value is dropped automatically through Vue's built-in top-level ref unwrapping.
import { reactive } from 'vue'
interface Product {
id: string
name: string
price: number
}
const productLookup = reactive(new Map<string, Product>())
function addProduct(product: Product) {
productLookup.set(product.id, product)
}
function removeProduct(id: string) {
productLookup.delete(id)
}
// productLookup.size and productLookup.get(id) are fully reactive
3. The destructuring pitfall: what breaks reactivity
The most common stumbling block with reactive Map and Set instances is destructuring, because methods like map.get or map.has lose their internal binding to this once pulled out of the proxy as standalone function references. A call like const { get, set } = productLookup followed by get('abc') throws a TypeError at runtime, because get internally expects to be called through the proxy instance to correctly access the underlying map.
The safe approach is to always reference the map or set instance as a whole and call methods directly on it, so productLookup.get(id) instead of a pre-destructured get function. Anyone who needs a single, reactive derived value, such as just the current size, should use a computed() that internally keeps reading productLookup.size, rather than destructuring size once and freezing it into a static snapshot.
// Wrong: breaks reactivity and the this binding
const { get, has } = productLookup
// get('abc') throws a TypeError at runtime
// Right: call methods directly on the instance
productLookup.get('abc')
productLookup.has('abc')
// Right for a derived, reactive size
import { computed } from 'vue'
const productCount = computed(() => productLookup.size)
4. Practical example: unique selection sets in a multi-select UI
In a table with multi-selection, for instance for bulk actions across several rows, a Set is the most fitting data structure for the selected IDs, because duplicate selection is structurally impossible and membership checks through has() run in constant time. An array would either require manual duplicate checking before every push(), or a linear includes() search through the entire list on every visibility check, which becomes noticeably slower on large tables.
The toggle logic for a single row shrinks to three lines with a Set: check whether the ID is already present, and depending on the result call add() or delete(). For select-all functionality, the set can simply be cleared with clear() or repopulated with all visible IDs, and reactive(new Set()) fits especially well here, because the template can then query selectedIds.has(row.id) directly for every row without setting up additional computed values.
import { reactive } from 'vue'
const selectedIds = reactive(new Set<string>())
function toggleSelection(id: string) {
if (selectedIds.has(id)) {
selectedIds.delete(id)
} else {
selectedIds.add(id)
}
}
function selectAll(ids: string[]) {
ids.forEach((id) => selectedIds.add(id))
}
function clearSelection() {
selectedIds.clear()
}
5. Practical example: lookup maps for fast access to detail data
Once a component repeatedly needs to access an object by ID within a larger list, for instance to look up full product data for every line item in a cart view, a companion lookup map alongside the actual array pays off. Instead of iterating through the whole array on every access with products.find(p => p.id === id), productMap.get(id) delivers the result in constant time regardless of the size of the original list.
This lookup map is typically derived from the original data source through a computed(), for example from an array loaded via an API, so it automatically rebuilds whenever the data source changes, without manually keeping two copies of the data in sync. For frequently updated, large lists with several hundred or thousand entries, this difference noticeably determines in practice whether a detail view reacts instantly or gets perceptibly sluggish with every additional entry.
6. Performance comparison: Map lookup versus Array.find()
Algorithmically, the difference between a map and an array for lookups comes down to complexity: Array.find() searches every element linearly in the worst case, giving it a complexity of O(n), while map.get() runs on average in constant time, O(1), thanks to hashing, regardless of the number of entries. On small lists with a few dozen entries this difference is barely measurable in practice, because modern JavaScript engines handle even small linear searches extremely fast.
The difference becomes relevant only with larger data volumes or access patterns that repeat the same lookup very frequently in a short time, for instance when rendering a long table where each row performs a lookup against a related list. In that scenario, many individual O(n) searches quickly add up to an overall quadratic runtime, while the same task stays linear with a pre-built map, which in most cases more than offsets the one-time cost of building the map.
7. ref() versus reactive() for Map and Set: which makes more sense
Both ref(new Map()) and reactive(new Map()) make a map reactive, but they differ in practical handling. With reactive(), you work directly on the returned instance, so productLookup.set(id, value) without .value, which reads more intuitively in script code and matches the usual use of reactive() for object-like structures. With ref(), .value must consistently be prepended, as in productLookup.value.set(id, value), which means more typing but has the advantage that the entire map can easily be replaced with a completely new instance at any time, for example productLookup.value = new Map(freshEntries).
In practice, reactive() is usually the more pleasant choice for Map and Set, as long as the collection is only ever changed through its own methods and never needs to be reassigned as a whole. Once a use case regularly swaps out the entire collection, for instance reloading all data after an API call, ref() is the more robust choice, because a variable wrapped with reactive() cannot be reassigned without losing the original reactive connection.
8. Watching Map and Set changes: the deep option and its limits
A watch() on a reactive map or set by default only reacts to a complete reassignment of the reference, not to individual set(), add() or delete() calls, because the reference to the proxy object itself does not change in those cases. To react to internal mutations, watch() must be called with the { deep: true } option, which lets Vue detect internal changes to the collection as well and fire the callback accordingly, even when the outer reference stays the same.
On very large maps or sets, { deep: true } can introduce noticeable overhead, because Vue has to inspect the internal structure on every mutation. For performance-critical cases it is often better to watch a targeted derived computed value instead, such as computed(() => productLookup.size), rather than deep-watching the entire collection, since a computed only recalculates automatically and efficiently when its actually read dependencies have changed.
9. Common mistakes and conclusion
The most common mistake is the destructuring issue described earlier, which breaks the this binding and throws a TypeError at runtime even though the code looks unremarkable at first glance. A second common mistake is setting up a watch() without { deep: true } on a map or set and then wondering why internal changes trigger no reaction, while a complete reassignment of the collection works without issue.
The takeaway: Map and Set are not exotic niche tools but fully supported reactivity primitives in Vue 3, often the clearer and faster choice over arrays for selection states and lookup structures. Always calling methods directly on the instance, using reactive() for incrementally mutated collections and ref() for collections that get swapped out wholesale, and reaching for { deep: true } or computed() where needed, gets the full potential out of both structures.
| Structure | Typical use | Access time | Reactivity in Vue |
|---|---|---|---|
| Array | Ordered list, iteration, rendering | O(n) for find() | Fully supported via reactive() |
| Set | Unique selection, membership checks | O(1) for has() | Fully supported via reactive() |
| Map | Key/value lookup, detail data by ID | O(1) for get() | Fully supported via reactive() |
| Object | Simple key/value pairs with string keys | O(1) for direct property access | Fully supported via reactive() |
Mironsoft
Vue architecture, Composition API, and Nuxt performance
Vue applications that don't get more complicated with every feature?
We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.
Architecture Review
Checking composables, state management, and component structure for maintainability.
Performance Audit
Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.
Nuxt Integration
Building robust, type-safe SSR/SSG setup and API integration.
10. Summary
Reactive Map and Set in Vue: The Essentials at a Glance
Set for selection
Guarantees unique values and delivers has() checks in constant time, ideal for multi-select states.
Map for lookup
Maps keys to values and replaces linear Array.find() with constant-time get() access.
Destructuring trap
Never destructure methods, always call them directly on the reactive instance, or the this binding breaks.
watch with deep
Internal mutations like set() or add() require watch() with the { deep: true } option.