Skip to content

Instantly share code, notes, and snippets.

@AlexVipond
Last active March 7, 2022 17:16
Show Gist options
  • Save AlexVipond/3be2803fef21ac6268855045483497f5 to your computer and use it in GitHub Desktop.
Save AlexVipond/3be2803fef21ac6268855045483497f5 to your computer and use it in GitHub Desktop.
Infinite render loops in Vue 3

Infinite render loops in Vue 3

Vue 3's fantastic reactivity system and component update cycle almost always protects you from triggering infinite render loops: never-ending reactive updates that cause your app to rapidly re-render until it crashes.

There are ways to make infinite render loops happen though! Let's study a few different pitfalls, so we can be prepared to debug our apps if we ever see one of the following errors:

  • Maximum recursive updates exceeded
  • too much recursion

Watchers that mutate their own dependencies

If you're using watchEffect, you won't ever run into this problem. watchEffect automatically collects its own dependencies, meaning that it detects which reactive references are accessed inside its callback, and re-runs its callback each time one of those dependencies changes.

But importantly, watchEffect protects against infinite render loops. Here's some code to show what I mean:

<script setup>
import { ref, watchEffect } from 'vue'

const count = ref(0)

// `watchEffect` knows that it should re-run the callback
// each time `count` changes. But the callback changes
// `count` too...won't that cause an infinite loop?
watchEffect(() => count.value++)
// The answer is "no", because `watchEffect` is smart enough
// to detect when its callback function changes `count`, and
// when `count` is changed by something else (like that button
// down in the template).
//
// If `watchEffect` detects that its callback function was
// the reason for the change in `count`, it won't run the
// function again.
</script>

<template>
  <div>
    <button @click="() => count++">
      increase
    </button>
  </div>
</template>

watch, on the other hand, doesn't have these kinds of safeguards. In fact, the simplest way to crash your Vue app is to use a watch callback to mutate one of that watchers dependencies:

import { ref, watch } from 'vue'

const count = ref(0)

// Watch `count`. Each time `count` changes, change it again.
watch(
	count,
  // In the callback, change `count`.
	() => count.value++,
)
  
// Change `count` once to set off the infinite loop.
count.value++

Here's an SFC playground to show this app-destroying code in action.

There's no real world use case for that kind of watch code. But if you see an infinite loop crashing your app, it's worth examining your watchers—you might have accidentally set one of them up to mutate its own dependencies.

v-for function refs that assign new values to reactive arrays

This is a much more niche case, but when you're learning how to use function refs, it can be an easy trap to fall into.

Take this code for example:

<template>
  <!--
    As shown in the Vue docs on function refs, we can
    bind a function to the `ref` attribute of a 
    `v-for` element. Vue will call that function for
    each rendered element, and we can use that function
    to store the elements in an array.
  -->
  <div v-for="num in [0, 1, 2]" :ref="setElement">
    {{ num }}
  </div>
</template>

<script setup>
import { ref } from 'vue'

// In our setup, we'll initialize a reactive array where
// we can store the elements.
const elements = ref([])

// Then we'll write the function ref that accepts each
// rendered element and stores it in the array.
const setElement = el => {
  // It might be tempting to have the function ref
  // assign a new value to the array, like this:
  elements.value = [...elements.value, el]
  // But that will cause an infinite render loop!
}
</script>

Here's an SFC playground showing that this code crashes the app.

The lesson to remember is: if you're writing a function ref to capture elements rendered by v-for, don't let the function ref reactively update the array where elements are being store. That will cause an infinite render loop.

It's worth noting that the .push() method, which normally would trigger reactive updates to arrays in Vue, gets special treatment so that it doesn't trigger reactive updates when called inside function refs.

In theory, you could write the function ref like this:

const setElement = el => {
  elements.value.push(el)
}

In RC (release candidate) versions of of Vue 3, this .push() usage actually caused an infinite render loop, but that behavior was improved before the official Vue 3 release.

But using .push() inside function refs will give you trouble if the source array can be reactively reordered during the component lifecycle. Here's an SFC playground with some more detail on this problem.

The fix is to do exactly what the Vue docs recommend: assign each element to a specific index in the array, like so:

<template>
  <!--
    Access the `index` from `v-for`, and use it to
    store the element in its exactly correct position
    in the array.
    
    Assigning to indices in arrays does not trigger
    reactive updates in Vue, so this code will not
    cause infinite render loops!
  -->
  <div
    v-for="(num, index) in [0, 1, 2]"
    :ref="el => elements[index] = el"
  >
    {{ num }}
  </div>
</template>

<script setup>
import { ref } from 'vue'
const elements = ref([])
</script>

A weird edge case that I don't even know how to describe

I highly doubt anyone will stumble across this case in production code—I only found it when I was writing up a contrived demo to explain some effect flush timing concepts.

The steps to reproduce are:

  • Use a function ref to capture an array of elements rendered by v-for
  • Using the onBeforeUpdate hook, reset the array of elements to an empty array. Vue will refill the array on every update, which is important and necessary if there's a chance that items in the v-for list could be added, removed, or reordered by user interaction.
  • Set up a watcher that watches the array of elements, and mutates a different piece of reactive data when the array updates
  • Rendered that piece of reactive data in the template, inside the v-for elements.

Here's the supremely contrived example:

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

// First, our function ref code:
const elements = ref([])
const setElement = (el, index) => elements.value[index] = el
// This includes the `onBeforeUpdate` hook, which is a best
// practice when working with `v-for` function refs.
onBeforeUpdate(() => elements.value = [])

// Create a reactive reference
const count = ref(0)

// Mutate that reactive reference using a watcher that
// watches the `elements` array. This watcher will run
// on every update, since the `elements` array gets reactively
// emptied out on every update.
watch(
  elements,
  () => {
    count.value++
  },
)
</script>

<template>
  <!--
    In the template, render the reactive data outside the
    `v-for` list:
  -->
  <div>count: {{ count }}</div>
  <!-- Set up the function ref to capture the v-for elements: -->
  <div
    v-for="(num, index) in [0, 1, 2]"
    :ref="el => setElement(el, index)"
  >
    {{ num }}
  </div>
  
  <!-- Allow the user to trigger reactive updates: -->
  <button @click="count++">update</button>
</template>

And here's an SFC playground where you can see the infinite loop happen. The app renders fine at first, but as soon as you click the "update" button, the infinite loop explodes.

I have no clue when this would be useful in a real world app, but if you find yourself staring into the abyss of this particular infinite render loop, know that there's a solution.

Set up your watcher with the flush: 'sync' option:

watch(
  elements,
  () => {
    count.value++
  },
  // With `flush: sync` in place, this watcher won't
  // trigger an infinite render loop!
  { flush: 'sync' }
)

That's the last infinite render pitfall I'm aware of! If you find any more, send them my way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment