Skip to the solution
MediumFrontend9 min read

What are composables in Vue?

391 views

The problem

Question presented to candidate: "Several of our components each re-implement 'listen to window resize and clean up on unmount' and 'fetch with a loading flag'. What is a composable, how would you write one for this, and what rules does it have to follow?"

What a strong answer should cover:

  • A composable is a function, named useX by convention, that uses Composition API primitives (ref, computed, watch, lifecycle hooks) to encapsulate stateful logic and returns refs and functions.
  • State created inside the function is per call, so each component gets its own copy; state hoisted to module scope is shared.
  • Lifecycle hooks inside a composable attach to the calling component, so cleanup (onUnmounted, onScopeDispose) happens automatically.
  • Composables must be called synchronously in setup() / <script setup> (or inside another composable or an effectScope).
  • Inputs should accept a value, a ref or a getter and normalise with toValue(); return a plain object of refs so callers can destructure.
  • Contrast with mixins (implicit, collision-prone) and with plain utility functions (stateless).

Clarifying questions expected:

  • "Should the state be per component or shared across the app? That decides where the ref lives."
  • "Does this need to run during SSR? Browser-only side effects belong in onMounted."

Code / implementation expected: Yes β€” a small composable with cleanup, used by two components to show independent state.

  • composables
  • reuse

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 Β· 11 sections

Read the code

VueThree composables: per-instance state, a listener that cleans up, and a ref-or-getter input
<script setup>
import { ref, computed, watchEffect, toValue, onMounted, onUnmounted, h, defineComponent } from "vue";

// 1. A composable: a plain function that uses Vue reactivity and returns refs.
function useCounter(start = 0) {
  const count = ref(start);
  const inc = () => count.value++;
  return { count, inc };
}

// 2. A composable that owns a side effect and cleans it up with the component.
function useEventListener(target, event, handler) {
  onMounted(() => target.addEventListener(event, handler));
  onUnmounted(() => {
    target.removeEventListener(event, handler);
    console.log(`[useEventListener] removed "${event}" listener`);
  });
}

// 3. A composable that accepts a plain value, a ref or a getter (toValue unwraps all three).
function useTitleCase(input) {
  return computed(() =>
    toValue(input).replace(/\b\w/g, (c) => c.toUpperCase()),
  );
}

// Each component that calls useCounter gets its own state.
const Widget = defineComponent({
  props: { label: String },
  setup(props) {
    const { count, inc } = useCounter(10);
    useEventListener(window, "keydown", (e) => {
      if (e.key === "+") inc();
    });
    return () => h("button", { class: "w", onClick: inc }, `${props.label}: ${count.value}`);
  },
});

const showSecond = ref(true);
const name = ref("ada lovelace");
const pretty = useTitleCase(name);          // a ref
const fixed = useTitleCase("grace hopper"); // a plain string
const live = useTitleCase(() => name.value + " jr"); // a getter
watchEffect(() => console.log("[watchEffect] pretty =", pretty.value));
</script>

<template>
  <Widget label="first" />
  <Widget v-if="showSecond" label="second" />
  <button id="drop" @click="showSecond = false">remove second</button>
  <input id="name" v-model="name" />
  <p>{{ pretty }} | {{ fixed }} | {{ live }}</p>
</template>
Composables outside a component: plain state works, lifecycle hooks warn, effectScope hosts watchers
import { ref, onUnmounted, effectScope, watch, nextTick } from "vue";
function useCounter(start = 0) { const count = ref(start); return { count, inc: () => count.value++ }; }
const { count, inc } = useCounter(5); inc(); inc();
console.log("outside a component, count =", count.value);
// shared state: the ref lives outside the function
const shared = ref(0);
function useShared() { return { shared, bump: () => shared.value++ }; }
const a = useShared(), b = useShared(); a.bump();
console.log("shared between callers:", a.shared.value, b.shared.value, a.shared === b.shared);
// lifecycle composable outside setup
function useTicker() { const t = setInterval(() => {}, 1000); onUnmounted(() => clearInterval(t)); return t; }
clearInterval(useTicker());
// effectScope as a host
const scope = effectScope();
scope.run(() => { const src = ref(1); watch(src, v => console.log("scope watcher saw", v)); src.value = 2; globalThis.src = src; });
await nextTick(); scope.stop(); globalThis.src.value = 3; await nextTick();
console.log("after scope.stop(), no more watcher output");

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 59 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 nextMedium

What is Pinia and why use it?

Continue