Skip to the solution
MediumFrontend10 min read

What is defineExpose() and why are <script setup> components closed by default?

501 views

The problem

Question presented to candidate: "After migrating a component from the Options API to <script setup>, a parent that used this.$refs.editor.content started reading undefined, and a child that used this.$parent.save() broke too. Explain why <script setup> components are closed by default, what defineExpose() changes, and whether you would just expose everything to fix it."

What a strong answer should cover:

  • An Options API component is open: its public instance (through template refs or $parent) exposes data, computed and methods. A <script setup> component is closed: none of its bindings are visible, only built-ins such as $el and $props.
  • How it is done: in dev the compiler returns bindings for the template but calls expose() with no arguments; in production the template is inlined and the bindings never leave the setup closure.
  • defineExpose({ ... }) re-opens a chosen part: the exposed object replaces the public instance's own properties, and refs in it are unwrapped.
  • Why closed is the better default: encapsulation and safe refactoring, no reaching into children or parents, a public API that can be typed, and the same behaviour in dev and prod builds.
  • Options API components can opt in to the same model with the expose: [...] option.
  • Exposing everything is the wrong fix; move the data to props/events, v-model, provide/inject or a store, and expose only commands.

Clarifying questions expected:

  • "What does the parent actually need from the editor: its content, or the ability to trigger something like save?"
  • "Are there tests that rely on reading internal state?"

Code / implementation expected: Optional β€” show the same child open, closed and exposing a single method, or describe the compiled output.

  • defineExpose
  • template-refs
  • script-setup

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

10 min read Β· 10 sections

Read the code

VueOpen (Options API), closed (script setup style) and exposing children side by side
<script setup>
import { ref, defineComponent, h } from "vue";

// 1. Options API: open by default. The parent sees everything on "this".
const OpenChild = defineComponent({
  data: () => ({ count: 1 }),
  methods: { reset() { this.count = 0; } },
  render() { return h("span", `open ${this.count} | `); },
});

// 2. What <script setup> compiles to in dev mode: bindings are returned
//    for the template, then expose() is called with no arguments.
const ClosedChild = defineComponent({
  setup(_, { expose }) {
    expose();
    const count = ref(2);
    const reset = () => (count.value = 0);
    return { count, reset };
  },
  render() { return h("span", `closed ${this.count} | `); },
});

// 3. The same child, opting in to one method and one ref (what defineExpose does).
const ExposingChild = defineComponent({
  setup(_, { expose }) {
    const count = ref(3);
    const secret = ref("internal");
    const reset = () => (count.value = 0);
    expose({ reset, count });
    return { count, secret, reset };
  },
  render() { return h("span", `exposing ${this.count}`); },
});

const open = ref(null);
const closed = ref(null);
const exposing = ref(null);
const report = ref("");

function inspect() {
  report.value = [
    `open: count=${open.value.count} reset=${typeof open.value.reset}`,
    `closed: count=${closed.value.count} reset=${typeof closed.value.reset} $el=${closed.value.$el.tagName}`,
    `exposing: count=${exposing.value.count} secret=${exposing.value.secret} keys=${Object.keys(exposing.value)}`,
  ].join(" / ");
}
</script>

<template>
  <OpenChild ref="open" />
  <ClosedChild ref="closed" />
  <ExposingChild ref="exposing" />
  <p>
    <button class="inspect" @click="inspect">inspect children</button>
    <button class="reset" @click="exposing.reset()">reset exposing child</button>
  </p>
  <p class="report">{{ report }}</p>
</template>
Vitest: the parent sees only the exposed API, but wrapper.vm sees everything
import { mount } from "@vue/test-utils";
import { test, expect } from "vitest";
import Parent from "./Parent.vue";
import Counter from "./Counter.vue";

test("parent sees only the exposed API", async () => {
  const w = mount(Parent);
  await w.vm.$nextTick();
  console.log("parent saw ->", w.find("p").text());
});

test("Vue Test Utils reaches past defineExpose", async () => {
  const w = mount(Counter);
  await w.find("button").trigger("click");
  console.log("wrapper.vm.count ->", w.vm.count, "| wrapper.vm.step ->", w.vm.step);
  expect(w.vm.count).toBe(5);
});

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 49 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

How do you fetch data in a Vue component (loading, errors, cancellation)?

Continue