Vue 3ReactSide EffectsFrontend

Side Effects in Vue 3 and React: Watchers, Effects, and Cleanup

Compare side effect handling in Vue 3 and React, including API requests, event listeners, timers, AbortController, watchers, useEffect dependencies, and cleanup.

·Updated ·12 min read·Counting...
Side Effects in Vue 3 and React: Watchers, Effects, and Cleanup

Side effects are operations that interact with the outside world instead of only returning a value: API requests, event listeners, timers, subscriptions, direct DOM access, and storage writes. Both Vue 3 and React give you tools for starting and cleaning up these effects, but the mental models differ.

The important habit is the same in both frameworks: every effect should have a clear start condition and a clear cleanup path.

What counts as a side effect

Common side effects:

  • Fetching data from an API.
  • Adding browser event listeners.
  • Starting setTimeout or setInterval.
  • Opening WebSocket or subscription connections.
  • Touching DOM APIs directly.
  • Reading or writing localStorage.

Rendering calculations are not side effects. If a value can be computed from props or state during render, do that instead of creating an effect.

Vue 3: lifecycle and watchers

Vue 3's Composition API gives you lifecycle hooks and watchers:

<script setup>
import { onMounted, watch, ref } from "vue"

const count = ref(0)

onMounted(() => {
  console.log("mounted")
})

watch(count, (value) => {
  console.log("count changed", value)
})
</script>

Use onMounted when the effect starts after the component is mounted. Use watch when the effect depends on reactive data changes.

Vue cleanup

Timers and listeners must be cleaned up:

<script setup>
import { onMounted, onUnmounted, ref } from "vue"

const count = ref(0)
let timerId

onMounted(() => {
  timerId = window.setInterval(() => {
    count.value += 1
  }, 1000)
})

onUnmounted(() => {
  window.clearInterval(timerId)
})
</script>

If an effect is tied to a watcher, put cleanup in the watcher flow so outdated work does not keep running.

React: useEffect

React uses useEffect for synchronization with external systems:

import { useEffect, useState } from "react"

function CounterLogger() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log("mounted or count changed", count)
  }, [count])

  return <button onClick={() => setCount((value) => value + 1)}>{count}</button>
}

The dependency array declares which values the effect uses. Do not remove dependencies just to silence ESLint; fix the data flow instead.

React cleanup

Return a cleanup function from useEffect:

import { useEffect, useState } from "react"

function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timerId = window.setInterval(() => {
      setCount((value) => value + 1)
    }, 1000)

    return () => window.clearInterval(timerId)
  }, [])

  return <div>{count}</div>
}

React runs cleanup before rerunning the effect and when the component unmounts.

Abort outdated requests

Use AbortController to prevent outdated requests from updating state:

useEffect(() => {
  const controller = new AbortController()

  async function load() {
    const response = await fetch(`/api/search?q=${query}`, {
      signal: controller.signal,
    })
    const data = await response.json()
    setResult(data)
  }

  load().catch((error) => {
    if (error.name !== "AbortError") console.error(error)
  })

  return () => controller.abort()
}, [query])

The same browser API can be used in Vue when a watcher or lifecycle hook starts a request.

Common mistakes

  • Using effects to compute values that could be derived during render.
  • Forgetting to remove event listeners.
  • Starting intervals without clearing them.
  • Ignoring outdated async requests.
  • Hiding dependency problems by disabling lint rules.
  • Mixing data fetching, DOM work, and unrelated state updates in one effect.

Subscribe to FreeMac

Weekly picks: free Mac software reviews, trusted source updates, alternatives, and low-friction guides.