Quasar QSelect does a lot: plain dropdowns, autocomplete, tag inputs, server search. Its API runs to dozens of props, and most forms only need a handful of them. This post covers the four QSelect setups I reach for in nearly every app, each as a complete component you can paste: an async search filter, multiple values as chips, object options that store only an id, and a custom option row.
All code runs on Quasar 2.35.
The classic case is picking one customer out of thousands. You can't load them all into options, so the select has to ask the server as the user types.
Three props turn QSelect into a search box:
use-input adds a text input inside the field;hide-selected and fill-input show the chosen label in that input, so the field looks like a normal text box once something is picked;input-debounce waits before searching (the default is 500 ms, which feels slow; 300 is a good middle ground).Then the @filter event does the work:
<template>
<q-select
v-model="customer"
:options="options"
option-label="name"
option-value="id"
label="Customer"
use-input
hide-selected
fill-input
input-debounce="300"
outlined
@filter="onFilter"
>
<template #no-option>
<q-item>
<q-item-section class="text-grey">
Type at least 2 letters
</q-item-section>
</q-item>
</template>
</q-select>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface Customer {
id: number;
name: string;
email: string;
}
const customer = ref<Customer | null>(null);
const options = ref<Customer[]>([]);
async function searchCustomers(term: string): Promise<Customer[]> {
const res = await fetch(`/api/customers?q=${encodeURIComponent(term)}`);
return res.json();
}
async function onFilter(val: string, update: (fn: () => void) => void, abort: () => void) {
if (val.length < 2) {
abort();
return;
}
try {
const results = await searchCustomers(val);
update(() => {
options.value = results;
});
} catch {
abort();
}
}
</script>How the handler works:
update function and an abort function.abort() tells QSelect there is nothing to show, and it closes the menu. Here that happens when fewer than two letters have been typed, or when the request fails.update(fn) runs fn, where you change options, and then opens the menu with the new list.Always change options inside the function you pass to update, not before calling it. QSelect tracks which search each call belongs to. If the user types an, then ann, and the an response arrives last, QSelect skips its callback, so slow responses never overwrite newer results. While a search is running, the field shows a spinner in place of the dropdown arrow, with no extra code.
option-label and option-value tell QSelect which keys to read when your objects don't use label and value. The model here holds the whole customer object, which is handy when the form also needs the email.
For tags, categories or assignees, the user picks several values. Add multiple, and use-chips to show each pick as a removable chip:
<template>
<q-select
v-model="labels"
:options="labelOptions"
label="Labels"
multiple
use-chips
use-input
new-value-mode="add-unique"
input-debounce="0"
max-values="5"
outlined
@filter="onFilter"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
const allLabels = ["bug", "feature", "docs", "design", "backend", "urgent"];
const labels = ref<string[]>([]);
const labelOptions = ref(allLabels);
function onFilter(val: string, update: (fn: () => void) => void) {
update(() => {
const needle = val.toLowerCase();
labelOptions.value = allLabels.filter((label) => label.toLowerCase().includes(needle));
});
}
</script>What each prop adds:
multiple, the model is an array. Start it as [], not null.use-chips draws each value as a chip with a remove button.use-input with a local @filter lets the user narrow a long list by typing. input-debounce="0" because filtering an array in memory is instant.new-value-mode="add-unique" lets the user press Enter to add a label that isn't in the list, and ignores duplicates. Use add to allow duplicates or toggle to remove a value when it is typed again. Leave the prop out if users must pick from the list only.max-values="5" stops new picks after five.If the chips need a different look, the selected-item slot replaces them. Its scope gives you the option (opt), its index and removeAtIndex(index) to wire up the remove button.
This is the setup behind the most common QSelect bug report: "my form saves the whole object instead of the id".
By default, when options are objects, QSelect puts the whole selected object in the model:
const options = [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
{ label: "Archived", value: "archived" },
];
// after picking "Published", the model is { label: "Published", value: "published" }Usually you want only "published", because that is what the API expects. Two props fix it:
<template>
<q-select
v-model="status"
:options="statusOptions"
label="Status"
emit-value
map-options
outlined
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
const statusOptions = [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
{ label: "Archived", value: "archived" },
];
const status = ref<string>("published");
</script>emit-value stores the option's value in the model, not the object.map-options goes the other way: it looks the stored value up in options to show its label.You need both. With emit-value alone, the model is right, but the field shows published instead of Published, because nothing maps the value back to a label. With map-options alone, nothing changes, since the model still holds the object.
map-options can only show a label if the matching option is in options. That matters for an edit form: the record loads with status: "published", but the options come from the server.
Since Quasar 2.28, QSelect handles this for you when you load options through @filter. If the model holds a value and there are no options to map it against, QSelect calls your @filter handler once with an empty string, without opening the menu, so the label shows up on its own. Opt out with no-option-prefetch if the parent loads the options itself.
This is also why setup 1 calls abort() for short searches without breaking anything: its model holds the whole object, so there is nothing to map. If you combine a server search with emit-value and map-options, let an empty search load the option for the current value instead of aborting.
Labels alone aren't always enough. A team picker reads better with an avatar and a role under each name. The option slot replaces each row in the menu:
<template>
<q-select
v-model="assignee"
:options="people"
option-label="name"
option-value="id"
label="Assignee"
emit-value
map-options
outlined
>
<template #option="{ itemProps, opt }">
<q-item v-bind="itemProps">
<q-item-section avatar>
<q-avatar size="32px">
<img :src="opt.avatar" :alt="opt.name">
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ opt.name }}</q-item-label>
<q-item-label caption>{{ opt.role }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</template>
<script setup lang="ts">
import { ref } from "vue";
const people = [
{ id: 1, name: "Ana Lima", role: "Design", avatar: "/avatars/ana.png" },
{ id: 2, name: "Tom Reyes", role: "Backend", avatar: "/avatars/tom.png" },
{ id: 3, name: "Mei Chen", role: "Frontend", avatar: "/avatars/mei.png" },
];
const assignee = ref<number | null>(null);
</script>The one line you must not forget is v-bind="itemProps". It carries the click handler, the focused and selected state, and the accessibility attributes. Without it, the rows render but clicking them does nothing and the keyboard can't reach them.
QSelect renders its menu with virtual scroll, so long lists stay fast. If your slot renders more than one root element per option, the Quasar docs ask you to add the q-virtual-scroll--with-prev class to every element after the first. One q-item per option, as above, avoids the issue.
The slot scope also has selected and toggleOption(opt). Those let you put a QToggle or QCheckbox in each row of a multiple select.
Once a setup works, you will want it in more than one form. Wrap it in a component and expose its model with defineModel, which is one line instead of a prop and an emit. The Vue defineModel guide walks through exactly that pattern with a wrapped QInput, and it applies unchanged to QSelect.
Why does my QSelect show the value instead of the label? You used emit-value without map-options. Add map-options.
Why does the menu not open with use-input? Your @filter handler never called update(). Every path through the handler must call either update() or abort().
How do I clear the field? Add clearable. It sets the model to null, so type your model to allow it.
How do I show a message when nothing matches? Use the no-option slot, as in setup 1, or the shorter no-option-label prop (Quasar 2.28+) for plain text.
These four setups cover most of the select fields in a typical admin form. For the fields around them, the Inputs Block has Quasar input designs you can copy and adapt, three of them Free.