Generically Typed Vue Components#
- Defining components in Setup
- Generic props
- Using SFC as a base component
- Extracting Prop Types from SFC components
- Generic Slots
- Generic Component Events
BaseGenericComponent.vue
<template>
  <pre>
    <slot :current-value="value" :old-value="oldValue">
      {{ value }}
    </slot>
  </pre>
</template>
<script setup lang="ts">
import { watch, defineProps, ref, PropType } from 'vue';
const props = defineProps({
  value: {
    type: null as PropType<unknown>,
    required: true,
  },
});
const oldValue = ref<unknown>();
watch(
  () => props.value,
  (_, oldVal) => {
    oldValue.value = oldVal;
  }
);
</script>
types.ts
export type ExtractComponentProps<TComponent> =
  TComponent extends new () => {
    $props: infer P;
  }
    ? P
    : never;
useGenericComponent.ts
import { defineComponent, h, VNode } from 'vue';
import BaseGenericComponent from './BaseGenericComponent.vue';
import { ExtractComponentProps } from './types';
// We also omit the `onChanged` event so we can overwrite it, same case as the `value` prop
// This is because events and props are treated the same in Vue 3 except that events have `on` prefix
// So if you want to convert an event to a prop you can follow that convention (`changed` => `onChanged`)
type NonGenericProps = Omit<ExtractComponentProps<typeof BaseGenericComponent>, 'value' | 'onChanged'>
interface GenericProps<TValue> extends NonGenericProps {
  value: TValue;
}
interface GenericSlotProps<TValue> {
  currentValue: TValue;
  oldValue: TValue;
}
export function useGenericComponent<TValue = unknown>() {
  // remember to grab the slots object off the second argument
  const wrapper = defineComponent((props: GenericProps<TValue>, { slots }) => {
    // Returning functions in `setup` means this is the render function
    return () => {
      // We pass the slots and event handlers through
      return h(BaseGenericComponent, props, slots);
    };
  });
  // Cast the wrapper as itself so we do not lose existing component type information
  return wrapper as typeof wrapper & {
    // we augment the wrapper type with a constructor type that overrides/adds
    // the slots type information by adding a `$slots` object with slot functions defined as properties
    new (): {
      // Same trick as `$slots`, we override the emit information for that component
      $emit: {
        (e: 'changed', value: TValue): void;
      };
      $slots: {
        // each function correspond to a slot and its arguments are the slot props available
        // this is the default slot definition, it offers the `GenericSlotProps` properties as slot props.
        // it should return an array of `VNode`
        default: (arg: GenericSlotProps<TValue>) => VNode[];
      };
    };
  };
}
How to extract prop types from SFC (you may need to turn on the dark mode to see the image here)
Here is an example of how this would work:
<script lang="ts" setup>
import { useGenericComponent } from './genericComponent';
const StringComponent = useGenericComponent<string>();
interface User {
  id: number;
  name: string;
}
const ObjectComponent = useGenericComponent<User>();
function onChange(value: User) {
  console.log(value);
}
</script>
<template>
  <!-- 🛑  This should complain now in Volar due to type error -->
  <StringComponent
    :value="str"
    v-slot="{ currentValue, oldValue }"
    @changed="onChange"
  >
    <div>current: {{ currentValue }}</div>
    <div>old: {{ oldValue }}</div>
  </StringComponent>
  <ObjectComponent
    :value="userObj"
    v-slot="{ currentValue, oldValue }"
    @changed="onChange"
  >
    <div>current: {{ currentValue }}</div>
    <div>old: {{ oldValue }}</div>
  </ObjectComponent>
</template>
Practical Generic Components#
This SelectInput component should:
- Accept a list of options.
- Emit the selected value and support v-modelAPI (acceptsmodeValueprop and emitsupdate:modelValueevent).
 Expose anitemslot to customize each option’s UI in the floating menu.
 Expose aselectedslot to customize the selected option UI.
https://stackblitz.com/edit/vitejs-vite-wemb2u?embed=1
Reducing component noise with Composition API#
- We can construct components on the fly in the setupfunction and use it in our template.
- We can create component wrappers that act as generic components
 This helps in a few ways ways:
- Predefine some Props
- Expose clearer APIs with explicit behavior
- Hide some complexity away
features/modal.ts
import { ref, defineComponent, h } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';
export function useModalDialog<TData = unknown>(
  onConfirmProp: (data: TData) => void
) {
  // The contextual data
  const data = ref<TData>();
  function onClose() {
    data.value = undefined;
  }
  function show(value: TData) {
    data.value = value;
  }
  function hide() {
    data.value = undefined;
  }
  const DialogComponent = defineComponent({
    inheritAttrs: false,
    setup(_, { slots, emit }) {
      function onConfirmed() {
        if (data.value !== undefined) {
          onConfirmProp(data);
        }
      }
      return () =>
        h(
          ModalDialog, 
          {
            onClose,
            onConfirmed,
            visible: data.value !== undefined,
          },
          {
            default: () => slots.default?.({ data: data.value }),
          }
        );
    },
  });
  return {
    DialogComponent:
      DialogComponent as typeof DialogComponent & {
        // we augment the wrapper type with a constructor type that overrides/adds
        // the slots type information by adding a `$slots` object with slot functions defined as properties
        new (): {
          $emit: {
            (e: 'confirmed', data: TData): void;
          };
          $slots: {
            default: (arg: { data: TData }) => VNode[];
          };
        };
      },
    show,
    hide
  };
}
Example Usage#
<script setup lang="ts">
import { ref } from 'vue';
import { useModalDialog } from '@/features/modal';
interface Item {
  id: number;
  name: string;
}
const items: Item[] = ref([
  //...
]);
const {
  show: onDeleteClick,
  DialogComponent: DeleteItemDialog,
  hide,
} = useModalDialog<Item>((item) => {
  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('Deleted', item);
  hide();
});
</script>
<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>
  <DeleteItemDialog
    v-slot="{ data: itemToDelete }"
  >
    Are you sure you want to delete {{ itemToDelete.name }}?
  </DeleteItemDialog>
</template>
“When I click an item, open a dialog for it, and when the action is confirmed let me know so I can delete the item”.
You could create some sort of a “shared” modal dialog that many components can reach out to and use. Maybe with the provide/inject API:
// In a parent page or component
const { DialogComponent, ...modalApi } = useModalDialog();
// make modal api available to child components
provide('modal', modalApi);
// Somewhere else in a child component:
const modal = inject('modal');
modal.show(data);
References: