Skip to content

Proxies and getters

This chapter is a deep dive. You don't need it for the exam, and Vue works without you having read it. But it shows how Vue notices that data have changed, and why some bugs with Vue look the way they do. Part 5 comes back to it.

Learning goals

After this chapter you can

  • write getters and setters and explain when their code runs,
  • intercept reading and writing an object with a Proxy,
  • explain how a reactive system notices changes with track and trigger,
  • describe how the example differs from Vue.

A getter looks like a field from outside but is a function that runs on every read. A setter runs on every write. You know both from the classes in the unit Classes and this, and in objects they work the same way.

getter.mjs
const reservation = {
from: new Date('2026-11-02'),
to: new Date('2026-11-04'),
get days() {
return (this.to - this.from) / 86_400_000 + 1
},
}
console.log(reservation.days)
reservation.to = new Date('2026-11-06')
console.log(reservation.days)
const item = {
storedDeposit: 20,
get deposit() {
return this.storedDeposit
},
set deposit(value) {
if (value < 10 || value > 80) throw new RangeError('The deposit is between 10 and 80 francs')
this.storedDeposit = value
},
}
item.deposit = 30
console.log(item.deposit)
try {
item.deposit = 120
} catch (error) {
console.log(error.message)
}

days computes anew on every read, so the value is right even after the end of the reservation has changed. The setter of deposit checks every new value before it stores it. Getters and setters show the principle that reactivity rests on. Reading and writing a field can trigger code. But they have a limit. Getters and setters only work for fields that are known when the code is written.

A Proxy wraps a whole object. Every access goes first to functions that you define, the so-called traps. get runs when a field is read, set when it is written, even for fields that don't exist yet.

proxy.mjs
const item = { name: 'Tent', availableFrom: null }
const watched = new Proxy(item, {
get(target, key) {
console.log(`read ${String(key)}`)
return target[key]
},
set(target, key, value) {
console.log(`written ${String(key)} = ${value}`)
target[key] = value
return true
},
})
watched.availableFrom = '2026-11-14'
console.log(watched.name)
console.log(item.availableFrom)

The proxy reports every access and then passes it on to the actual object. That's why the new date ends up in item as well. A set has to return true, otherwise the write counts as failed, and in a module JavaScript then reports a TypeError.

With a proxy, you can tell which code reads which fields. If one of these fields changes later, you can run exactly this code again. That is the core of Vue's reactivity.

reactive.mjs
let activeEffect = null
const effectsByKey = new Map()
function reactive(object) {
return new Proxy(object, {
get(target, key) {
// Notes which effect reads which key
if (activeEffect) {
if (!effectsByKey.has(key)) effectsByKey.set(key, new Set())
effectsByKey.get(key).add(activeEffect)
}
return target[key]
},
set(target, key, value) {
target[key] = value
// Runs every effect that read this key again
for (const effect of effectsByKey.get(key) ?? []) effect()
return true
},
})
}
function watchEffect(effect) {
activeEffect = effect
effect()
activeEffect = null
}
const counts = reactive({ available: 49, total: 60 })
watchEffect(() => console.log(`${counts.available} of ${counts.total} things are in`))
counts.available = 48
counts.total = 62

watchEffect remembers the function as the active effect and runs it once. While it runs, it reads counts.available and counts.total, and the get trap notes for both fields that this effect needs them. Vue calls this step track. When someone later writes one of the fields, the set trap runs all noted effects again. Vue calls this step trigger. That's how a new line appears after every change without anyone calling the output by hand.

The example shows the idea, and Vue implements it more thoroughly.

  1. Vue notes the effects per object and per field, in a WeakMap with the objects as keys. The example notes them per field only, so two objects with the same fields get in each other's way, as the challenge shows.
  2. reactive() returns a proxy as in the example. For single values such as a number there is ref(). Since a number can't be wrapped in a proxy, Vue puts it into an object with a getter and setter for value.
  3. Vue doesn't run effects right after every change. It collects them and updates the interface once, before the browser redraws the page.
  4. Vue makes nested objects reactive only when someone reads them.

This explains a bug that part 5 shows in more detail. If you take a reactive object apart into single variables with destructuring, you read the values from the proxy once. The variables then hold ordinary values, and no effect is triggered anymore when the object changes.

The exercises of this deep dive run right in the browser. You can start their solutions with "Run" and change them with "Edit".

Exercise Getting started

A basket has a list items with things and their deposit. Give it a getter total that returns the sum of the deposits. Check that total is right after another thing has been added.

Solution
solution-1.mjs
const basket = {
items: [
{ name: 'Tent', deposit: 50 },
{ name: 'Sleeping bag', deposit: 20 },
],
get total() {
return this.items.reduce((sum, item) => sum + item.deposit, 0)
},
}
console.log(basket.total)
basket.items.push({ name: 'Sleeping mat', deposit: 10 })
console.log(basket.total)

The getter computes anew with reduce on every read, so the sum is right after push without any further steps. A field total that you compute once when creating the basket would be outdated afterwards.

Exercise Going further

Write a function checked(item) that wraps a thing in a proxy. The proxy allows only deposits between 10 and 80 francs and reports a RangeError otherwise. All other fields can be changed freely.

Solution
solution-2.mjs
function checked(item) {
return new Proxy(item, {
set(target, key, value) {
if (key === 'deposit' && (typeof value !== 'number' || value < 10 || value > 80)) {
throw new RangeError(`Invalid deposit ${value}`)
}
target[key] = value
return true
},
})
}
const drill = checked({ name: 'Drill', deposit: 20 })
drill.deposit = 30
console.log(drill.deposit)
try {
drill.deposit = 5
} catch (error) {
console.log(error.name, error.message)
}

The set trap checks only the field deposit and lets all others through. Unlike a setter, the proxy acts on the whole object, so the check also applies when the field didn't exist yet when the thing was created.

Exercise Challenge

In the reactive system of the example, two objects get in each other's way. When the field available of a tent changes, the effect of a drill runs too, even though it reads only the drill. Rebuild the system so that it notes the effects per object and per field, as Vue does.

Solution
solution-3.mjs
let activeEffect = null
// Every reactive object gets its own map from keys to effects
const effectsByTarget = new WeakMap()
function track(target, key) {
if (!activeEffect) return
if (!effectsByTarget.has(target)) effectsByTarget.set(target, new Map())
const effectsByKey = effectsByTarget.get(target)
if (!effectsByKey.has(key)) effectsByKey.set(key, new Set())
effectsByKey.get(key).add(activeEffect)
}
function trigger(target, key) {
for (const effect of effectsByTarget.get(target)?.get(key) ?? []) effect()
}
function reactive(object) {
return new Proxy(object, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
return true
},
})
}
function watchEffect(effect) {
activeEffect = effect
effect()
activeEffect = null
}
const drill = reactive({ name: 'Drill', available: true })
const tent = reactive({ name: 'Tent', available: true })
watchEffect(() => console.log(drill.name, drill.available))
tent.available = false
drill.available = false

A WeakMap gives every object a Map of its own from fields to effects. The change to the tent finds no effect in the tent's map, and the output shows only the two lines of the drill. A WeakMap doesn't hold on to its keys. When nobody needs an object anymore, JavaScript may clear it from memory together with its entries. Vue uses the same structure.

Self-check

  1. When does the code of a getter run?
  2. What can a proxy do that a setter can't?
  3. What does a reactive system note when a field is read, and what does it do when a field is written?
  4. Why does a variable taken from a destructured reactive object lose its reactivity?
Answers
  1. Every time the field is read.
  2. A proxy intercepts access to all fields of an object, including fields that are added later. A setter applies only to the one field it was written for.
  3. When a field is read, it notes which effect needs which field. When a field is written, it runs the effects that need the changed field.
  4. Destructuring reads the value from the proxy once. The variable then holds an ordinary value, and access to it no longer goes through the proxy.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.