How do you write a Vue 3 component without an SFC whose setup() returns a render function, and still let a parent call its methods?
answer
- defineComponent in a .ts file
- h() inside the returned function
- a render function crowds out bindings
- expose from the setup context
- template refs see only what is exposed
basics
~10 sDefine the component with defineComponent, return a render function built with h() from setup(), and call expose({ method }) from the setup context; a parent's template ref then reaches exactly the exposed members.
solid answer
~40 sIn a `.ts` file, `defineComponent({ props, emits, setup(props, { emit, expose }) { ... } })` keeps state in local refs and returns a render function such as `() => h('button', { onClick: increment }, count.value)`. Returning a function means `setup()` cannot also return bindings, so nothing it declares is visible to a parent that holds a template ref. The fix is `expose({ reset })`: the parent's ref then points at a proxy of the exposed object (refs unwrapped) plus built-in public properties such as `$el`. Call `expose()` once per setup; with no argument it closes the instance entirely. Since Vue 3.3, `defineComponent((props, ctx) => () => h(...), { props: [...] })` is a shorter, generic-friendly form of the same thing.
code
ts · 21 linesimport { defineComponent, h, ref } from 'vue'
export const Counter = defineComponent({
props: { step: { type: Number, default: 1 } },
emits: ['change'],
setup(props, { emit, expose }) {
const count = ref(0)
function increment() {
count.value += props.step
emit('change', count.value)
}
function reset() {
count.value = 0
}
expose({ reset }) // a parent's template ref can call reset(), nothing else
return () => h('button', { onClick: increment }, `Count: ${count.value}`)
},
})go deeper
Recall that setup() may return a render function built with h(), and that expose() is how a parent's template ref reaches a method.
Explain why a render-function return hides setup's locals, what the exposed proxy contains, and the once-per-setup and plain-object rules.
Choose between template, render function and the 3.3 function signature for a library component, and keep its exposed surface small and action-based.
Set a policy for imperative component APIs in a shared library: what may be exposed, how it is typed, and when props and events must be used instead.
## When a component has no SFC Most Vue 3 components live in `.vue` files, where the SFC compiler turns the template into a render function. Some do not: components in a design-system library written in `.ts` or `.tsx`, components that build their output programmatically, or pages using Vue without a build step. For those, `setup()` returning a **render function** is the standard Composition API shape. ## Building it 1. **Wrap the definition in `defineComponent()`**, which gives TypeScript the props and emits types. At runtime it returns the options object unchanged. 2. **Declare props and emits** as options, as any component does. 3. **Create state in `setup()`** with `ref()`, `computed()` and composables. 4. **Return a function** that calls `h()` (or returns JSX). Vue runs it inside the component's render effect, so it re-runs whenever the reactive state it reads changes. 5. **Call `expose()`** with the members a parent may use through a template ref. Returning `h('div')` itself, instead of a function that returns it, is a common slip; Vue warns in development that setup should return a render function, because a VNode is a single snapshot that would never update. ## Why expose() is needed here With a template, `setup()` returns an object of bindings, and when a parent takes a template ref to the child, Vue gives it the component's public instance, where those bindings are visible. With a render function, `setup()` returns a function and nothing else, so its local functions are unreachable from outside. `expose(obj)` from the setup context solves that. Once it has been called, a parent's template ref no longer receives the full public instance. It receives a proxy over the exposed object, with refs unwrapped, which still forwards built-in public properties such as `$el` and `$props`. Rules that follow from the source: - `expose()` should be called **once per setup**; calling it again logs a development warning. - It expects a **plain object**; passing an array or a ref triggers a warning. - `expose()` with no argument sets an empty exposed object: the instance is **closed**, and the parent sees only the built-in `$` properties. ## Comparison of the ways to write such a component | Form | Where the markup comes from | Parent access to methods | |---|---|---| | `setup()` returning bindings + `template` string | runtime template compilation | public instance, unless `expose()` limits it | | `setup()` returning a render function | `h()` or JSX in the function | only through `expose()` | | `defineComponent((props, ctx) => () => ..., { props })` (3.3+) | `h()` or JSX | only through `ctx.expose()` | | `<script setup>` in an SFC | compiled template | only through `defineExpose()` | The template-string form needs a **full build** of Vue that includes the compiler, such as `vue.global.js` or `vue.esm-browser.js`, or a bundler alias to `vue/dist/vue.esm-bundler.js`. With the default runtime-only build, Vue warns that the component provided a template option but runtime compilation is not supported in this build, and the component renders nothing. ## The function signature Vue 3.3 added an alternative `defineComponent()` signature: pass a function that works like `setup()`, receiving props and the context, and returning a render function, plus a second argument with options such as `props`. Its main benefit is TypeScript generics in TSX. Runtime props still have to be declared in that options object. ## Design notes - Expose **actions**, such as `focus()`, `reset()` or `scrollToTop()`, not internal state; a parent that reaches into a child's refs is coupled to its implementation. - Prefer props and emits for data flow; an exposed method is an imperative escape hatch. - Keep the render function pure: read state and build VNodes, with side effects in watchers or lifecycle hooks.
- If the setup() returned an object of bindings instead, could the parent reach them without expose()?Yes. Without an `expose()` call, a parent's template ref receives the component's public instance, and bindings returned from `setup()` are visible on it. Calling `expose()` narrows that surface to the exposed object plus built-in `$` properties such as `$el`.
- Why is this pattern more common in component libraries than in application code?Libraries often ship `.ts` or `.tsx` sources, build markup programmatically (tables, virtual lists, headless wrappers), and want an explicit public API for imperative methods such as `focus()`. Application components usually benefit more from SFC templates, compile-time optimisations and `<script setup>`.
expose() is a shop counter: a parent holding a template ref can take only what has been put on the counter, never what is in the back room. Calling it with nothing leaves the counter bare.
saying these in an interview costs you the question
- A parent can call any function declared inside setup() through a template ref.
- setup() can return a render function and a bindings object together.
- expose() with no arguments exposes everything setup() declared.
- A template string option works with Vue's default runtime-only build.
- Returning h('div') from setup() is equivalent to returning a render function.