A composable that registers its own onUnmounted

Cleanup belongs beside the thing it cleans up, and a composable can register lifecycle hooks because it runs inside the component’s setup scope.

export function usePolling(fetcher, intervalMs = 30000) {
  const data = ref(null)
  let timer = null

  onMounted(() => {
    refresh()
    timer = setInterval(refresh, intervalMs)
  })

  onUnmounted(() => clearInterval(timer))

  return { data, refresh }
}

A mixin could register hooks too and could not be used twice in one component without the two instances fighting over one variable — the composable has its own closure, so two calls are two intervals. The constraint is that the hooks only register while there is a current instance, so calling a composable after an await inside setup silently registers nothing. The vue/no-lifecycle-after-await lint rule exists for exactly this and is worth enabling.