Building Better Abstractions with Vue Render Functions

Building Better Abstractions with Vue Render Functions

Learn a practical pattern for using Vue render functions to build better abstractions and simplify your component architecture

Abdelrahman Awad

Abdelrahman Awad

January 8, 2026

Building Better Abstractions with Vue Render Functions

Render functions are an alternative way to render HTML in Vue.js using JavaScript. For the most part, using templates is the preferred way for the majority of use cases.

The premise is simple: you write the template with JavaScript function calls.

      import { h } from 'vue'

const vnode = h(
  'div', // type
  { id: 'foo', class: 'bar' }, // props
  [
    h('h1', 'Hello World'),
    h('p', 'This is a paragraph'),
  ]
)

    

Looks simple, right? But then it doesn't really beat this:

      <template>
  <div id="foo" class="bar">
    <h1>Hello World</h1>
    <p>This is a paragraph</p>
  </div>
</template>

    

Templates are more natural, easier to read, and easier to maintain. So why would you use render functions?

The documentation says:

there are situations where we need the full programmatic power of JavaScript. That's where we can use the render function.

But it doesn't really explain when you would need to use it. You could make a case for writing more optimized render logic, but the Vue compiler is pretty smart and hard to beat in that regard.

Instead of guessing imaginary use cases, I will show what I actually use them for in my work. It is not really about rendering stuff, but more about building better abstractions for your apps.

Render functions as a component factory

Usually, you can create a programmatic component like this:

      import { h, defineComponent } from 'vue'

const MyComponent = defineComponent({
  setup(props) {
    // Setup
  },
  render() {
    // Render
    return h('div', 'Hello World')
  }
})

    

I'm particularly interested in the shorter way to express this, which is a function passed to defineComponent:

      import { h, defineComponent } from 'vue'

const MyComponent = defineComponent((props, { slots }) => {
  // Setup Scope

  return () => {
    // Render Scope, we can also pass down the slots to the render function
    return h('div', 'Hello World', slots)
  }
})

    

This is a more concise way to express the component, but it also has the advantage of being able to access whatever you do in the setup scope within the render scope. Since this is just a function that returns a function, we can take advantage of JavaScript closures here.

This isn't super useful yet, but if you put it into a factory function, things get interesting.

      import { h, defineComponent } from 'vue'

function useMyComponent(config) {
  // Factory Scope
  const MyComponent = defineComponent((props) => {
    // Setup Scope
    return () => {
      // Render Scope
      return h('div', 'Hello World')
    }
  })

  return MyComponent;
}

    

In that example, I have expressed my factory function as a composable that returns a component. This is really useful because it gives you an additional scope to configure the component on the fly.

Now let's see how we can use this in a real example.

Why Confirmation Dialogs Give Me the "Ick"

One thing that continuously bothers me when building forms, CRUD interfaces, tables with editable data, or any UI that requires user confirmation, is the confirmation dialog state.

Usually, you have a traditional modal component. You control its display with an isOpen prop or even a v-model binding. Maybe it has a default slot for the content and a footer slot for the buttons.

Let's imagine you are building the confirmation modal for deleting a repository in GitHub. You may have something like this:

      <script setup>
const isDeletingRepository = ref(false);
const repository = ref({ name: 'my-repository' });

function onCancel() {
  isDeletingRepository.value = false;
}

function onConfirm() {
  isDeletingRepository.value = false;
  // Delete the repository
}
</script>

<template>
  <button @click="isDeletingRepository = true">Delete Repository</button>

  <ModalDialog :is-open="isDeletingRepository">
    <template #default>
      <p>Are you sure you want to delete the repository {{ repository.name }}?</p>
    </template>

    <template #footer>
      <button @click="onCancel">Cancel</button>
      <button @click="onConfirm">Delete</button>
    </template>
  </ModalDialog>
</template>

    

Now, this is just the code for the dialog alone for one resource. But if you have multiple resources to delete, you will have to duplicate this code multiple times.

So, you can end up with something like this:

      <script setup>
const isDeletingRepository = ref(false);
const onCancelRepositoryDeletion = () => {}
const onConfirmRepositoryDeletion = () => {}

const isDeletingUser = ref(false);
const onCancelUserDeletion = () => {}
const onConfirmUserDeletion = () => {}

const isDeletingTeam = ref(false);
const onCancelTeamDeletion = () => {}
const onConfirmTeamDeletion = () => {}
</script>

<template>
  <button @click="isDeletingRepository = true">Delete Repository</button>
  <button @click="isDeletingUser = true">Delete User</button>
  <button @click="isDeletingTeam = true">Delete Team</button>

  <ModalDialog :is-open="isDeletingRepository">
    <!-- ... -->
  </ModalDialog>

  <ModalDialog :is-open="isDeletingUser">
    <!-- ... -->
  </ModalDialog>

  <ModalDialog :is-open="isDeletingTeam">
    <!-- ... -->
  </ModalDialog>
</template>

    

This can keep going on and on. So, the first idea we might have is to create a ConfirmationDialog component that abstracts some of that away. Let's do that.

      <script setup lang="ts">
const props = defineProps<{
  cancelButtonText?: string;
  confirmButtonText?: string;
}>();

const isOpen = defineModel('isOpen');
const emit = defineEmits(['confirm']);
</script>

<template>
  <ModalDialog :is-open="isOpen">
    <template #default>
      <slot />
    </template>

    <template #footer>
      <button @click="isOpen = false">{{ cancelButtonText ?? 'Cancel' }}</button>
      <button @click="emit('confirm')">{{ confirmButtonText ?? 'Confirm' }}</button>
    </template>
  </ModalDialog>
</template>

    

Now our example with the three modals would look like this:

      <script setup>
const isDeletingRepository = ref(false);
const onConfirmRepositoryDeletion = () => {}

const isDeletingUser = ref(false);
const onConfirmUserDeletion = () => {}

const isDeletingTeam = ref(false);
const onConfirmTeamDeletion = () => {}
</script>

<template>
  <button @click="isDeletingRepository = true">Delete Repository</button>
  <button @click="isDeletingUser = true">Delete User</button>
  <button @click="isDeletingTeam = true">Delete Team</button>

  <ConfirmationDialog :is-open="isDeletingRepository" @confirm="onConfirmRepositoryDeletion">
    <!-- ... -->
  </ConfirmationDialog>

  <ConfirmationDialog :is-open="isDeletingUser" @confirm="onConfirmUserDeletion">
    <!-- ... -->
  </ConfirmationDialog>

  <ConfirmationDialog :is-open="isDeletingTeam" @confirm="onConfirmTeamDeletion">
    <!-- ... -->
  </ConfirmationDialog>
</template>

    

This is a bit nicer. Since we now localized the cancel logic to the component, it means we no longer need to handle it. However, we still need an open state controller and a confirmation callback handler to run for each modal.

During all my experience working with Vue.js, I try to have as much logic contained within isolated units of code, be it components, composables, or even utility functions. One of the strategies I use is to reduce the amount of dependencies each unit of code needs to have, so it can be reused in different contexts more easily.

For components, it would be fewer props. For composables and functions, fewer arguments, and so on.

Components like this one give me the "ick" because they need the same number of dependencies each time they are used. If you have 100 modals, you will have to pass the same number of dependencies 100 times.

In these cases, I ask myself: what is the nature of those dependencies? What is necessary (the goal) and what is an accessory (how to get there)?

For the confirmation dialog, I would classify the confirm event as the necessary one, and the isOpen prop as the accessory.

All I want from a confirmation dialog is to put a certain action behind a gate. If the user confirms, then the action is executed. If the user cancels, then the action is not executed. Whether the confirmation dialog is open or not is not important; it's just a mechanism to open and lower the gate.

With that in mind, let's see how render functions can help us here.

Designing the Perfect API

For building abstractions like this one, I usually try to imagine what kind of API I want to use first. I forget about how to implement it and just focus on how I would like to use it.

In our case of the confirmation dialog, I want a simple composable that lets me create a confirmation dialog with a simple API to gate an action.

So it seems at first like it should be a function that accepts a callback and returns a component.

      import { h, defineComponent } from 'vue'

export function useConfirmationDialog(action) {

  return defineComponent((props) => {
    return () => {
      // What do we want to render?
    }
  })
}

    

The cool thing about render functions is that you can render another component. So we can just borrow the ConfirmationDialog component we already have and use it in our render function.

And since it accepts an onConfirm callback, we can just pass our action to it.

      import { h, defineComponent } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  return defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        onConfirm: action,
      }, slots)
    }
  })
}

    

This wouldn't work just yet because we still need to actually control the open state of the dialog. So, we can start by just creating a simple ref to control the open state.

      import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  const isOpen = ref(false)

  return defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        isOpen: isOpen.value,
        onConfirm: action,
      }, slots)
    }
  })
}

    

Lastly, we need a way to actually toggle the state on. Simply returning a component isn't enough; we need to return a toggling function and the component as well.

We can pack them neatly into an object.

      import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  const isOpen = ref(false)

  const Dialog = defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        isOpen: isOpen.value,
        onConfirm: action,
      }, slots)
    }
  })

  return {
    Dialog,
    confirm: () => isOpen.value = true,
  }
}

    

Now we can use this composable to create a confirmation dialog with a really neat API.

      <script setup>
import { useConfirmationDialog } from './useConfirmationDialog'

const DeleteRepository = useConfirmationDialog(() => {
  console.log('confirmed!')
})
</script>

<template>
  <button @click="DeleteRepository.confirm">Confirm</button>

  <DeleteRepository.Dialog>
    <p>Are you sure you want to delete the repository?</p>
  </DeleteRepository.Dialog>
</template>

    

With this API, we can not only rename the dialog component to match the action we are performing, but we skipped passing any props to the dialog component, since the only thing we really needed is the confirmation callback and a way to open the dialog.

You can do all sorts of interesting things with this. Since we do have confirmText and cancelText props on the ConfirmationDialog component, we can pass them as arguments to either the useConfirmationDialog composable or the confirm function itself. It is up to you.

Let me show you how both approaches would look.

Approach 1: Configuring at creation

      import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action, confirmText, cancelText) {
  const isOpen = ref(false)

  const Dialog = defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        isOpen: isOpen.value,
        onConfirm: action,
        confirmButtonText: confirmText,
        cancelButtonText: cancelText,
      }, slots)
    }
  })

  return {
    Dialog,
    confirm: () => isOpen.value = true,
  }
}

// Usage
const DeleteRepository = useConfirmationDialog(() => {
  console.log('confirmed!')
}, 'Delete Repository', 'Cancel')

    

Approach 2: Configuring at call time

      import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  const isOpen = ref(false)
  const confirmTextState = ref('')
  const cancelTextState = ref('')

  return {
    Dialog: defineComponent((props, { slots }) => {
      return () => {
        return h(ConfirmationDialog, {
          isOpen: isOpen.value,
          onConfirm: action,
          confirmText: confirmTextState.value,
          cancelText: cancelTextState.value,
        }, slots)
      }
    }),
    confirm: (confirmText, cancelText) => {
      isOpen.value = true
      confirmTextState.value = confirmText ?? 'Confirm'
      cancelTextState.value = cancelText ?? 'Cancel'
    },
  }
}

// Usage
const DeleteRepository = useConfirmationDialog(() => {
  console.log('confirmed!')
})

// On a button @click handler
DeleteRepository.confirm('Delete', 'Cancel')

    

I prefer the second approach because I can always change the confirmation text and cancel text at any time when I open the dialog.

Let's go back to our example with the three modals and see how well this new API fits.

      <script setup>
import { useConfirmationDialog } from './useConfirmationDialog'

const DeleteRepository = useConfirmationDialog(() => {
  console.log('Repository deleted!')
})

const DeleteUser = useConfirmationDialog(() => {
  console.log('User deleted!')
})

const DeleteTeam = useConfirmationDialog(() => {
  console.log('Team deleted!')
})
</script>

<template>
  <button @click="DeleteRepository.confirm()">Delete Repository</button>
  <button @click="DeleteUser.confirm()">Delete User</button>
  <button @click="DeleteTeam.confirm()">Delete Team</button>

  <DeleteRepository.Dialog>
    <p>Are you sure you want to delete the repository?</p>
  </DeleteRepository.Dialog>

  <DeleteUser.Dialog>
    <p>Are you sure you want to delete the user?</p>
  </DeleteUser.Dialog>

  <DeleteTeam.Dialog>
    <p>Are you sure you want to delete the team?</p>
  </DeleteTeam.Dialog>
</template>

    

No events, no state, no props, or even provide/inject. Just a simple API built on closures and render functions.

Here is a live demo of the example.

This pattern can be used in many situations when you need to colocate logic and UI and pack them into a single unit of code. Here are a few more examples:

  • Table with Pagination: A composable that fetches data and hooks them to a Table component and a Pagination component, allowing you to create a table with pagination without a global store.
  • Notifications & Toasts: A composable that returns a Notification component and a trigger function, allowing you to spawn toasts without a global store.
  • Form State with UI: A useForm composable that returns the form state but also a wrapper component that handles the loading overlay and error messages automatically.

Conclusion

Render functions are a powerful tool that can be used to render UI with extreme flexibility and control. But you can also use them to build abstractions not possible with SFC templates alone. By using both together, you can create some really interesting patterns.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)