Skip to the solution
HardFrontend9 min read

What is effectScope() and why do composables use it for cleanup?

204 views

The problem

Question presented to candidate: "You are writing a composable library. Why would you clean up with onScopeDispose and effectScope instead of onUnmounted, and how would you build a composable that several components share without duplicating its watchers?"

What a strong answer should cover:

  • Composables are called from components, Pinia stores, other composables and tests; onUnmounted works only in the first and warns elsewhere.
  • onScopeDispose attaches cleanup to whatever scope is active, which inside a component is the component scope, so it covers every case.
  • Effects created after an await or in a callback are not owned by any scope and leak; capture a scope synchronously and scope.run() later.
  • Shared composables: run the composable once in a detached effectScope(true), count consumers, and stop() when the last one disposes (the pattern behind VueUse createSharedComposable).
  • Testing: run a composable inside effectScope() and call stop() to assert cleanup, no component mount needed.
  • Use getCurrentScope() to make cleanup optional when the composable may run outside any scope.

Clarifying questions expected:

  • "Will this composable be used inside Pinia stores or only in components?"
  • "Should several components share one instance of the state, or get their own?"

Code / implementation expected: Yes β€” a shared composable with reference counting, or a demonstration of an async watcher leak and its fix.

  • effectScope
  • watch
  • composables

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 9 sections

Read the code

VueShared composable: one detached scope, reference-counted consumers
<script setup>
import { defineComponent, effectScope, h, onScopeDispose, ref, watch } from "vue";

// A global "store" every consumer reads from.
const ticks = ref(0);
const log = ref([]);
const say = (msg) => log.value.push(msg);

// createShared: run the composable ONCE in a detached scope, count consumers,
// and stop the scope when the last consumer goes away.
function createShared(composable) {
  let users = 0;
  let state;
  let scope;
  return () => {
    users++;
    if (!scope) {
      scope = effectScope(true); // detached: not owned by whichever component came first
      state = scope.run(composable);
    }
    onScopeDispose(() => {      // runs when the calling component unmounts
      users--;
      if (users === 0) {
        scope.stop();
        scope = state = undefined;
      }
    });
    return state;
  };
}

// The expensive composable: one watcher, one cleanup, however many consumers.
const useTickWatcher = createShared(() => {
  say("shared scope: started");
  const seen = ref(0);
  watch(ticks, (n) => { seen.value = n; say("shared watcher saw " + n); });
  onScopeDispose(() => say("shared scope: stopped"));
  return { seen };
});

const Badge = defineComponent({
  props: { name: String },
  setup(props) {
    const { seen } = useTickWatcher();
    return () => h("span", { class: "badge" }, props.name + ":" + seen.value + " ");
  },
});

const showA = ref(true);
const showB = ref(true);
</script>

<template>
  <button id="tick" @click="ticks++">tick ({{ ticks }})</button>
  <button id="a" @click="showA = !showA">toggle A</button>
  <button id="b" @click="showB = !showB">toggle B</button>
  <p>
    <Badge v-if="showA" name="A" />
    <Badge v-if="showB" name="B" />
  </p>
  <ol>
    <li v-for="(line, i) in log" :key="i">{{ line }}</li>
  </ol>
</template>
VueLeak: a watcher created in a timer survives unmount, a scoped one does not
<script setup>
import { defineComponent, effectScope, h, onScopeDispose, ref, watch } from "vue";
const ticks = ref(0);
const show = ref(true);

const Leaky = defineComponent({
  setup() {
    // Created later, outside setup's synchronous run: no owner scope.
    setTimeout(() => watch(ticks, (n) => console.log("  [leaky] watcher still alive, saw", n)));
    return () => h("span", "leaky ");
  },
});

const Fixed = defineComponent({
  setup() {
    const scope = effectScope();          // created synchronously, so the component owns it
    setTimeout(() => scope.run(() => watch(ticks, (n) => console.log("  [fixed] watcher saw", n))));
    onScopeDispose(() => console.log("  [fixed] scope disposed with the component"));
    return () => h("span", "fixed ");
  },
});

</script>

<template>
  <button @click="ticks++">tick</button>
  <button @click="show = !show">toggle</button>
  <p v-if="show"><Leaky /><Fixed /></p>
</template>

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 90 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

How does server-side rendering work in Vue (renderToString, hydration)?

Continue