---
url: "https://vueda.dev/v3/guides/choices-and-lookups.md"
title: "Choice-Backed Fields and Lookup Models"
---

# Choice-Backed Fields and Lookup Models

This guide shows how to give a field a set of options, how to build a widget that loads those options, and how to declare a [Lookup](/v3/reference/glossary.md#lookup) model for a table of codes and names.

A [Choice-Backed Field](/v3/reference/glossary.md#choice-backed-field) carries `choices` in its [Model Info](/v3/reference/glossary.md#model-info) entry. The value is either the full list of `{label, value}` pairs, or `true` when the options are another model's rows. Choice values are always strings. [Primary Key and Identifier Discipline](/v3/core-concepts/pk-and-identifier-discipline.md#choice-identifier-value-semantics) explains why.

## Make a Field Choice-Backed

1. Declare the options on the server.
    - For a fixed set, give the model field Django's [`choices`](https://docs.djangoproject.com/en/6.1/ref/models/fields/#django.db.models.Field.choices). For a filter, use a [`ChoiceFilter`](https://django-filter.readthedocs.io/en/stable/ref/filters.html#django_filters.filters.ChoiceFilter).
    - For another model's rows, add a [`ForeignKey`](https://docs.djangoproject.com/en/6.1/ref/models/fields/#django.db.models.ForeignKey) or [`ManyToManyField`](https://docs.djangoproject.com/en/6.1/ref/models/fields/#django.db.models.ManyToManyField) to that model. For a filter, use a [`ModelChoiceFilter`](https://django-filter.readthedocs.io/en/stable/ref/filters.html#django_filters.filters.ModelChoiceFilter). To offer the values that a column already holds, use an [`AllValuesFilter`](https://django-filter.readthedocs.io/en/stable/ref/filters.html#django_filters.filters.AllValuesFilter).

2. Give the related model a label. Each option shows the related row's [Formatted Name](/v3/reference/glossary.md#formatted-name). [Create a CRUD Surface](/v3/guides/create-crud-surface.md#the-formatted-name-contract) describes how to set it.

3. Make the options reachable. The source that the default UI loads options from depends on how you declare the field:

    | Model info `choices` | Declared as                                                                                                                              | Default UI loads options from                                                                                                                                   |
    | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | A list               | Fixed choices on a field or filter                                                                                                       | The list itself, with no request                                                                                                                                |
    | `true`               | A form field with a [`PrimaryKeyRelatedField`](https://www.django-rest-framework.org/api-guide/relations/#primarykeyrelatedfield), the default for a relation | The related model's list endpoint, searched as the user types                                                                                                   |
    | `true`               | A form field with a [`SlugRelatedField`](https://www.django-rest-framework.org/api-guide/relations/#slugrelatedfield)                                         | [List field choices](/v3/reference/api/rest/vueda.info/vueda.info_model_info_choices_list.md)                                                                            |
    | `true`               | A filter                                                                                                                                 | [List filterset field choices](/v3/reference/api/rest/vueda.info/vueda.info_model_info_filter_choices_list.md), when the filter form opens or the URL holds a value for the filter |

    For the list endpoint, register and route the related model like any other model, as [Create a CRUD Surface](/v3/guides/create-crud-surface.md) describes. Users who fill the field need `read` and `list` on the related model.

    For the choice endpoints, users need `read` on the source model. When the options are another model's rows, they also need `list` on that model. The filter choices endpoint builds its options only from the rows that the registered viewset's `list` view reads. For another model's rows, the user must also be able to list each related row. [Filter Choices and Permission Surfaces](/v3/core-concepts/filtering-and-ordering-semantics.md#filter-choices-and-permission-surfaces) describes that rule. [`ModelInfoChoicesViewSet`](/v3/reference/api/py/vueda.info.viewsets/ModelInfoChoicesViewSet.md) and [`ModelInfoFilterSetChoicesViewSet`](/v3/reference/api/py/vueda.info.viewsets/ModelInfoFilterSetChoicesViewSet.md) describe each endpoint's checks, errors, ordering, and filter narrowing.

4. Check the model info. Request `GET /routes/vueda.info/model_info/<app_label>/<model>/` ([Get model info](/v3/reference/api/rest/vueda.info/vueda.info_model_info_retrieve.md)). Find the field under `model_fields` or the filter under `model_filtering`. Fixed choices appear as a list. Another model's rows appear as `"choices": true`, with `app_label` and `model` naming the related model.

## Build a Widget That Loads Choices

The default widgets cover most choice-backed fields. Write a custom widget when a field needs a presentation that they do not offer. The widget below shows the options as radio buttons.

1. Write the widget. For a field whose `choices` is `true`, the form passes the widget `fieldApp`, `fieldModel`, and `fieldName`, which name the source model and the field. [`useModelChoices`](/v3/reference/api/js/use/useModelChoices/functions/useModelChoices.md) loads the options from the field choices endpoint:

    ```vue
    <script setup>
    import { useModelChoices } from "@vueda/use/useModelChoices.js";
    import { WIDGET_EMITS, WIDGET_PROPS, useWidget } from "@vueda/use/useWidget.js";
    import { computed, ref, toRef } from "vue";

    const props = defineProps({
        ...WIDGET_PROPS,
        fieldApp: { type: String, required: true },
        fieldModel: { type: String, required: true },
        fieldName: { type: String, required: true },
    });
    const emit = defineEmits([...WIDGET_EMITS]);
    const widget = useWidget(props, emit);

    // Load the options once the field holds a value or gets focus.
    const hasBeenFocused = ref(false);
    const modelChoices = useModelChoices({
        [props.fieldName]: {
            app: toRef(props, "fieldApp"),
            model: toRef(props, "fieldModel"),
            intendToFetch: computed(() => widget.state.combinedValue != null || hasBeenFocused.value),
            isFilter: false,
        },
    });
    const options = computed(() => modelChoices.choices[props.fieldName]?.results ?? []);

    // Choice values are strings, so compare the field's value as a string.
    const selected = computed({
        get: () => (widget.state.combinedValue == null ? null : String(widget.state.combinedValue)),
        set: (value) => {
            widget.state.combinedValue = value;
        },
    });
    </script>

    <template>
        <fieldset @focusin.once="hasBeenFocused = true">
            <label v-for="option in options" :key="option.value">
                <input v-model="selected" type="radio" :value="option.value" :disabled="props.readOnly" />
                {{ option.label }}
            </label>
        </fieldset>
    </template>
    ```

    [`WIDGET_PROPS`](/v3/reference/api/js/use/useWidget.md#WIDGET_PROPS) and [`useWidget`](/v3/reference/api/js/use/useWidget/functions/useWidget.md) connect the widget to the form. Set `isFilter: true` to load from the filter choices endpoint instead.

2. Assign the widget to the field with [`setConfig`](/v3/reference/api/js/stores/storeModelConfig/methods/setConfig.md):

    ```js
    import OrderStateRadios from "./OrderStateRadios.vue";
    import { storeModelConfig } from "@vueda/stores/storeModelConfig.js";

    storeModelConfig().setConfig(
        { app: "store", model: "customerorder" },
        { widgetComponents: { order_state: OrderStateRadios } },
    );
    ```

    [Customize Field and Widget Rendering](/v3/guides/custom-field-widget-rendering.md) describes the widget contract and the other ways to assign a widget.

3. Check the result. Open the create view and focus the field. The browser sends one request to `/routes/vueda.info/model_info_choices/<app_label>/<model>/<field>/`.

The client asks for one page of 200 options per field. Issue [#381](https://github.com/arrai-innovations/vueda/issues/381) tracks reaching rows past the first 200. Each new fetch sends a new request, and components that ask at the same time share one. [Reactive Data Flow](/v3/core-concepts/reactive-data-flow.md#cached-results) describes the choice store.

## Declare a Lookup Model

[`Lookup`](/v3/reference/api/py/vueda.core.models/Lookup.md) is an abstract base for tables of codes and names, such as order states. It subclasses [`FormattedNameBaseModel`](/v3/reference/api/py/vueda.core.models/FormattedNameBaseModel.md), the base that it shares with `VuedaModel`. It adds a unique `code`, a `name`, and a `formatted_name` column that copies `name`.

1. Declare the model, and point a relation at it:

    ```python
    from django.db import models

    from vueda.core.models import BaseModelMeta, Lookup, VuedaModel


    class OrderState(Lookup):
        class Meta(BaseModelMeta):
            ordering = ["name"]


    class CustomerOrder(VuedaModel):
        name = models.CharField(max_length=255)
        order_state = models.ForeignKey(OrderState, on_delete=models.PROTECT)

        class Meta(BaseModelMeta):
            pass
    ```

    Base each `Meta` on [`BaseModelMeta`](/v3/reference/api/py/vueda.core.models/BaseModelMeta.md), which gives the model VUEDA's `create`, `read`, `update`, `delete`, and `list` permissions.

2. Add a serializer, filterset, and viewset:

    ```python
    from vueda.core.filters import VuedaFilterSet
    from vueda.core.serializers import VuedaLookupSerializer
    from vueda.core.viewsets import VuedaViewSet


    class OrderStateSerializer(VuedaLookupSerializer):
        class Meta(VuedaLookupSerializer.Meta):
            model = OrderState


    class OrderStateFilterSet(VuedaFilterSet):
        class Meta:
            model = OrderState
            fields = ["id", "code", "name"]


    class OrderStateViewSet(VuedaViewSet):
        queryset = OrderState.objects.all()
        serializer_class = OrderStateSerializer
        filterset_class = OrderStateFilterSet
        search_fields = ["name", "code"]
    ```

    [`VuedaLookupSerializer`](/v3/reference/api/py/vueda.core.serializers/VuedaLookupSerializer.md) lists `id`, `code`, `name`, and `formatted_name`. The form's relation widget sends typed text to `search_fields`. It loads several selected rows at once through the `id` filter.

3. Route the viewset under the model name, and register it in your app's [`ready()`](https://docs.djangoproject.com/en/6.1/ref/applications/#django.apps.AppConfig.ready):

    ```python
    router.register(r"orderstate", OrderStateViewSet)
    ```

    ```python
    def ready(self):
        from vueda.info import register

        from .serializers import OrderStateSerializer
        from .viewsets import OrderStateViewSet

        register(OrderStateSerializer, OrderStateViewSet)
    ```

    [Create a CRUD Surface](/v3/guides/create-crud-surface.md#router-and-url-wiring) describes the router prefix and [model-info registration](/v3/guides/create-crud-surface.md#model-info-registration).

4. Create and apply the migration. Add the rows through a data migration or the model's CRUD views.

5. Grant permissions. Users who set `order_state` need `read` and `list` on the lookup model, for example `store.read_orderstate` and `store.list_orderstate`. Users who maintain the table also need `create`, `update`, and `delete`.

The model info for `customerorder` now shows `"choices": true` on `order_state`, with `model` set to `orderstate`. The create and update forms search `OrderState` rows and show each row's `formatted_name`.
