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-model
API (acceptsmodeValue
prop and emitsupdate:modelValue
event).
Expose anitem
slot to customize each option’s UI in the floating menu.
Expose aselected
slot 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
setup
function 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: