---
title: "Computed properties: cache, don't recalculate · vuejs.school"
description: "Build a filtered, sorted list with computed, see why it runs once per change, and learn the two mistakes that leave a computed wrong or stale."
canonical_url: "https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate"
meta:
  "og:description": "Build a filtered, sorted list with computed, see why it runs once per change, and learn the two mistakes that leave a computed wrong or stale."
  "og:image": "https://vuejs.school/_og/d/a_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRl%2Cdescription_~QnVpbGQgYSBmaWx0ZXJlZCwgc29ydGVkIGxpc3Qgd2l0aCBjb21wdXRlZCwgc2VlIHdoeSBpdCBydW5zIG9uY2UgcGVyIGNoYW5nZSwgYW5kIGxlYXJuIHRoZSB0d28gbWlzdGFrZXMgdGhhdCBsZWF2ZSBhIGNvbXB1dGVkIHdyb25nIG9yIHN0YWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wdXRlZC1wcm9wZXJ0aWVzLWNhY2hlLWRvbnQtcmVjYWxjdWxhdGUi%2Cs_oNAAM2UG5fvFTLtQ.png"
  "og:image:alt": "Computed properties: cache, don't recalculate · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Computed properties: cache, don't recalculate · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRl%2Cdescription_~QnVpbGQgYSBmaWx0ZXJlZCwgc29ydGVkIGxpc3Qgd2l0aCBjb21wdXRlZCwgc2VlIHdoeSBpdCBydW5zIG9uY2UgcGVyIGNoYW5nZSwgYW5kIGxlYXJuIHRoZSB0d28gbWlzdGFrZXMgdGhhdCBsZWF2ZSBhIGNvbXB1dGVkIHdyb25nIG9yIHN0YWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wdXRlZC1wcm9wZXJ0aWVzLWNhY2hlLWRvbnQtcmVjYWxjdWxhdGUi%2Cs_oNAAM2UG5fvFTLtQ.png"
  "twitter:image:alt": "Computed properties: cache, don't recalculate · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## A function in the template runs on every render

Start with a shop page: a search box, a sort choice, and a list. A first version often puts the work in a function and calls it from the template.

*src/components/ProductList.vue*

```
<script setup lang="ts">
import { ref } from 'vue'

type Product = { id: number; name: string; price: number }

const products = ref<Product[]>([
  { id: 1, name: 'Notebook', price: 4 },
  { id: 2, name: 'Fountain pen', price: 28 },
  { id: 3, name: 'Desk lamp', price: 35 },
  { id: 4, name: 'Pencil case', price: 9 },
])
const query = ref('')
const sortBy = ref<'name' | 'price'>('name')
const likes = ref(0)

function visibleProducts() {
  console.count('visibleProducts ran')
  const q = query.value.trim().toLowerCase()
  return products.value
    .filter(p => p.name.toLowerCase().includes(q))
    .sort((a, b) => (sortBy.value === 'name' ? a.name.localeCompare(b.name) : a.price - b.price))
}
</script>

<template>
  <input v-model="query" placeholder="Search">
  <p>{{ visibleProducts().length }} products</p>
  <ul>
    <li v-for="p in visibleProducts()" :key="p.id">{{ p.name }}, £{{ p.price }}</li>
  </ul>
  <button type="button" @click="likes++">Like this shop ({{ likes }})</button>
</template>
```

It works, and the list is right. Open the console, though. Every render calls `visibleProducts()` twice, once for the count and once for the list. Click the Like button and the counter goes up by two again, even though the products, the query and the sort did not change. The component re-rendered because `likes` changed, and a function in the template runs on every render, whatever caused it.

Four products cost nothing. Four thousand, filtered and sorted twice on every render, start to show.

## Why the computed runs once per change

In `<script setup>`, replace the function with a computed:

*src/components/ProductList.vue*

```
import { computed, ref } from 'vue'

const visibleProducts = computed(() => {
  console.count('visibleProducts ran')
  const q = query.value.trim().toLowerCase()
  return products.value
    .filter(p => p.name.toLowerCase().includes(q))
    .sort((a, b) => (sortBy.value === 'name' ? a.name.localeCompare(b.name) : a.price - b.price))
})
```

In the template, drop the brackets: `visibleProducts.length` and `v-for="p in visibleProducts"`. A computed is a ref, so in script you read it as `visibleProducts.value`, and the template unwraps it for you.

Now the console shows one run when the page loads, and one run per keystroke in the search box. The Like button shows none. That is the cache at work. Here is what Vue does with the getter, the function you pass to `computed`:

1. While the getter runs, Vue records every reactive value it reads: `query`, `sortBy`, `products`, and every product field the filter and the sort touched. Those are its dependencies.
2. Vue caches the result. Until a dependency changes, every read returns it straight away, so the count and the list share one run.
3. When one of the dependencies changes, Vue marks the computed as out of date, and the next read runs the getter again.

Two details follow from that:

- A computed is lazy. If nothing reads it, for example because the only template code that uses it sits inside a `v-if` that is false, the getter does not run at all.
- Since Vue 3.4, a computed tells the things that read it to update only when its new value differs from the old one. A computed returning `true` that recalculates to `true` again triggers nothing.

The `.sort()` here is safe because `.filter()` already returned a new array. Sorting `products.value` directly would reorder your source data from inside a getter, which is the next mistake.

## Keep the getter pure

The [Vue guide](https://vuejs.org/guide/essentials/computed.html) puts it plainly: a computed getter should only compute and return a value. No changing other state, no requests, no touching the DOM. The tempting version is a computed that loads data:

*src/components/UserCard.vue**Breaks***

```
<script setup lang="ts">
import { computed } from 'vue'

type User = { id: number; name: string }
const props = defineProps<{ userId: number }>()

const user = computed(async () => {
  const res = await fetch(`/api/users/${props.userId}`)
  return (await res.json()) as User
})
</script>

<template>
  <p>{{ user.name }}</p>
</template>
```

An `async` function always returns a Promise, so `user` holds a Promise, not a user. The paragraph stays empty, and `vue-tsc` reports `Property 'name' does not exist on type 'Promise<User>'`.

Loading data is a side effect: it happens in response to a change. That is the job of a watcher.

*src/components/UserCard.vue**Works***

```
<script setup lang="ts">
import { ref, watch } from 'vue'

type User = { id: number; name: string }
const props = defineProps<{ userId: number }>()

const user = ref<User | null>(null)

watch(
  () => props.userId,
  async (id) => {
    const res = await fetch(`/api/users/${id}`)
    user.value = (await res.json()) as User
  },
  { immediate: true },
)
</script>

<template>
  <p v-if="user">{{ user.name }}</p>
  <p v-else>Loading…</p>
</template>
```

The watcher runs once straight away because of `immediate: true`, then again whenever `userId` changes, and writes the result into a ref. The same rule covers smaller side effects: a getter that saves to `localStorage`, logs analytics or sets another ref belongs in a watcher too.

## A computed you can set

A computed is read-only by default. Assigning to one prints `[Vue warn] Write operation failed: computed value is readonly`. Sometimes a derived value should be editable, and a "mark all as done" checkbox is the classic case. Pass an object with `get` and `set`:

*src/components/TodoList.vue*

```
<script setup lang="ts">
import { computed, ref } from 'vue'

type Todo = { id: number; text: string; done: boolean }

const todos = ref<Todo[]>([
  { id: 1, text: 'Read the lesson', done: true },
  { id: 2, text: 'Run the example', done: false },
  { id: 3, text: 'Take the quiz', done: false },
])

const allDone = computed({
  get: () => todos.value.length > 0 && todos.value.every(t => t.done),
  set: (value: boolean) => {
    for (const t of todos.value) t.done = value
  },
})
</script>

<template>
  <label><input v-model="allDone" type="checkbox"> Mark all as done</label>
  <ul>
    <li v-for="t in todos" :key="t.id">
      <label><input v-model="t.done" type="checkbox"> {{ t.text }}</label>
    </li>
  </ul>
</template>
```

Tick every row and the top box ticks itself, because the getter re-runs. Tick the top box and the setter writes to every row. Notice that the setter does not store anything of its own. It changes the source state, and the getter derives the new value from that.

## Chain computeds so each step caches separately

A computed can read another computed, and each keeps its own cache. In the shop list, split `visibleProducts` into a filter step and a sort step, and add a total. The template now reads `sorted` and `total`:

*src/components/ProductList.vue*

```
const filtered = computed(() => {
  const q = query.value.trim().toLowerCase()
  return products.value.filter(p => p.name.toLowerCase().includes(q))
})

const sorted = computed(() =>
  [...filtered.value].sort((a, b) =>
    sortBy.value === 'name' ? a.name.localeCompare(b.name) : a.price - b.price,
  ),
)

const total = computed(() => sorted.value.reduce((sum, p) => sum + p.price, 0))
```

Changing the sort now re-runs `sorted` and `total`, but not `filtered`, because the query and the products did not change. Typing in the search box runs all three. Note the `[...filtered.value]` copy: `.sort()` reorders the array it is called on, and that array belongs to `filtered`'s cache.

## A computed goes stale when an input is not reactive

A computed only tracks reactive values: refs, `reactive()` objects, props, other computeds. It still reads anything else when the getter runs, but a change to it does not mark the computed as out of date.

*src/components/CartTotal.vue**Breaks***

```
<script setup lang="ts">
import { computed, ref } from 'vue'

const subtotal = ref(40)
let discount = 0

const total = computed(() => subtotal.value - discount)

function applyCode() {
  discount = 5
}
</script>

<template>
  <p>Total: £{{ total }}</p>
  <button type="button" @click="applyCode">Apply code</button>
</template>
```

Click the button and the total stays at £40. `discount` is a plain variable, so Vue never saw it as a dependency and nothing marks the computed as out of date. The confusing part comes later: change `subtotal` and the total suddenly includes the discount, because the getter finally ran again. Make every input reactive:

*src/components/CartTotal.vue**Works***

```
const subtotal = ref(40)
const discount = ref(0)

const total = computed(() => subtotal.value - discount.value)

function applyCode() {
  discount.value = 5
}
```

The same goes for `Date.now()`, `localStorage.getItem()` and `window.innerWidth`. A computed that reads them keeps the answer from its last run until one of its reactive inputs changes. Put the value in a ref and update the ref when it changes, for example from a timer or an event listener.

## Debugging with onTrack and onTrigger

When a computed updates too often or not at all, ask Vue what it tracked. `computed` takes a second argument with two callbacks. Here they are on `filtered`:

*src/components/ProductList.vue*

```
const filtered = computed(
  () => {
    const q = query.value.trim().toLowerCase()
    return products.value.filter(p => p.name.toLowerCase().includes(q))
  },
  {
    onTrack(e) {
      console.log('tracked', e.target, e.key)
    },
    onTrigger(e) {
      console.log('out of date because', e.key, 'became', e.newValue)
    },
  },
)
```

`onTrack` fires once for every reactive read during the getter: the `query` ref, the `products` ref, the array, and each product's `name`. For a ref, `e.key` is `'value'` and `e.target` is the ref itself. `onTrigger` fires when one of those changes and marks the computed out of date.

Both callbacks only run in development builds; Vue ignores them in production. To pause instead of logging, put a `debugger` statement in either callback.

If a value you expected is missing from the `onTrack` list, the getter either did not read it on that run or read something that is not reactive. The second is the stale computed from the previous section, and the fix is to put the value in a ref.

**Check it stuck**3 questions from the Computed properties quiz.

[**Practice now**](https://vuejs.school/learn/beginner/computed-properties?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 5 min read**Forms and inputs: v-model modifiers, checkboxes and selects**](https://vuejs.school/community/blog/forms-and-inputs-v-model-modifiers-checkboxes-and-selects) [Tutorial · 6 min read**v-if, v-show and v-for: the directives you use every day**](https://vuejs.school/community/blog/v-if-v-show-and-v-for)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-05-26","description":"Build a filtered, sorted list with computed, see why it runs once per change, and learn the two mistakes that leave a computed wrong or stale.","name":"Computed properties: cache, don't recalculate","url":"https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate","breadcrumb":{"@id":"https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-05-26","description":"Build a filtered, sorted list with computed, see why it runs once per change, and learn the two mistakes that leave a computed wrong or stale.","headline":"Computed properties: cache, don't recalculate","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRl%2Cdescription_~QnVpbGQgYSBmaWx0ZXJlZCwgc29ydGVkIGxpc3Qgd2l0aCBjb21wdXRlZCwgc2VlIHdoeSBpdCBydW5zIG9uY2UgcGVyIGNoYW5nZSwgYW5kIGxlYXJuIHRoZSB0d28gbWlzdGFrZXMgdGhhdCBsZWF2ZSBhIGNvbXB1dGVkIHdyb25nIG9yIHN0YWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wdXRlZC1wcm9wZXJ0aWVzLWNhY2hlLWRvbnQtcmVjYWxjdWxhdGUi%2Cs_oNAAM2UG5fvFTLtQ.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Computed properties: cache, don't recalculate","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRl%2Cdescription_~QnVpbGQgYSBmaWx0ZXJlZCwgc29ydGVkIGxpc3Qgd2l0aCBjb21wdXRlZCwgc2VlIHdoeSBpdCBydW5zIG9uY2UgcGVyIGNoYW5nZSwgYW5kIGxlYXJuIHRoZSB0d28gbWlzdGFrZXMgdGhhdCBsZWF2ZSBhIGNvbXB1dGVkIHdyb25nIG9yIHN0YWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wdXRlZC1wcm9wZXJ0aWVzLWNhY2hlLWRvbnQtcmVjYWxjdWxhdGUi%2Cs_oNAAM2UG5fvFTLtQ.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRlIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Q29tcHV0ZWQgcHJvcGVydGllczogY2FjaGUsIGRvbid0IHJlY2FsY3VsYXRl%2Cdescription_~QnVpbGQgYSBmaWx0ZXJlZCwgc29ydGVkIGxpc3Qgd2l0aCBjb21wdXRlZCwgc2VlIHdoeSBpdCBydW5zIG9uY2UgcGVyIGNoYW5nZSwgYW5kIGxlYXJuIHRoZSB0d28gbWlzdGFrZXMgdGhhdCBsZWF2ZSBhIGNvbXB1dGVkIHdyb25nIG9yIHN0YWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wdXRlZC1wcm9wZXJ0aWVzLWNhY2hlLWRvbnQtcmVjYWxjdWxhdGUi%2Cs_oNAAM2UG5fvFTLtQ.png"}]}
```