Tricks for Vue Composition API

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

Copy


<slot :current-value="value" :old-value="oldValue">
{{ value }}
</slot>



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;
}
);


types.ts

Copy
export type ExtractComponentProps<TComponent> =
TComponent extends new () => {
$props: infer P;
}
? P
: never;

useGenericComponent.ts

Copy
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:

Copy

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);
}



<StringComponent
:value="str"
v-slot="{ currentValue, oldValue }"
@changed="onChange"
>
current: {{ currentValue }}
old: {{ oldValue }}
</StringComponent>
<ObjectComponent
:value="userObj"
v-slot="{ currentValue, oldValue }"
@changed="onChange"
>
current: {{ currentValue }}
old: {{ oldValue }}
</ObjectComponent>


Practical Generic Components

This SelectInput component should:

Accept a list of options.

Emit the selected value and support v-model API (accepts modeValue prop and emits update:modelValue event).
Expose an item slot to customize each option’s UI in the floating menu.
Expose a selected 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

Copy
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

Copy

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();
});



<li
v-for="item in items"
:key="item.id"
@click="onDeleteClick(item)"
>
{{ item.name }}


<DeleteItemDialog
v-slot="{ data: itemToDelete }"
>
Are you sure you want to delete {{ itemToDelete.name }}?
</DeleteItemDialog>


“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:

Copy
// 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:

https://logaretm.com/blog/generic-type-components-with-composition-api/#defining-components-in-setup

https://logaretm.com/blog/reducing-component-noise-with-composition-api/