Radix Select en blanco al editar: el value no coincide

Editas un registro y el campo de selección (por ejemplo "Sucursal") aparece en blanco: no muestra el valor guardado y tampoco el texto de ayuda (placeholder), aunque en el listado el mismo registro sí muestra el nombre. Este es el uso habitual con el Select de shadcn/ui, que envuelve el de Radix UI:

<Select value={form.branchId} onValueChange={(v) => setForm({ ...form, branchId: v })}>
  <SelectTrigger>
    <SelectValue placeholder="Selecciona una sucursal" />
  </SelectTrigger>
  <SelectContent>
    {branches.map((b) => (
      <SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
    ))}
  </SelectContent>
</Select>

El problema es silencioso y peligroso: si el formulario envía ese campo vacío como null, al guardar se borra la asociación. Este artículo explica cuándo el Select queda en blanco, cómo diagnosticarlo y cómo evitar tanto el campo vacío como la pérdida de datos.

Por qué pasa

La documentación de Radix describe Select.Value como la parte que refleja el valor seleccionado: por defecto muestra el texto del elemento seleccionado y, con la propiedad placeholder, muestra un contenido alternativo "cuando el select no tiene valor". El código fuente (select.tsx del repositorio de Radix) concreta cómo se decide cada caso:

  • El placeholder solo se muestra cuando value es la cadena vacía o undefined.
  • El texto del valor no sale del value en sí, sino del SelectItem que coincide: el SelectItemText del elemento seleccionado se envía al nodo del SelectValue. Un elemento está seleccionado cuando su value es estrictamente igual (===) al del Select.

Por tanto, si el value tiene contenido pero ningún SelectItem coincide, no se muestra el placeholder (porque hay valor) y tampoco ningún texto (porque no hay elemento que lo aporte). El resultado es un botón en blanco. Se comprobó en un navegador con @radix-ui/react-select 2.2.6 y 2.4.0, con el mismo resultado en ambas:

Valor del SelectElementos disponiblesLo que se ve
"2"incluyen "2"la etiqueta del elemento
"zzz"no incluyen "zzz"en blanco, sin data-placeholder
"" o undefinedcualquierael placeholder
nullcualquieraen blanco, no el placeholder
2 (número)incluyen "2"en blanco
"2"llegan después de renderizaren blanco hasta que llegan; luego, la etiqueta

Las causas habituales en una pantalla de edición son:

  • La consulta de opciones no incluye la opción guardada. Por ejemplo, el endpoint filtra por un identificador equivocado (de otra empresa o inquilino), por estado (solo activos) o pagina y la opción guardada queda fuera. El listado puede seguir mostrando el nombre porque lo obtiene de la relación del registro y no de la lista de opciones.
  • Tipos distintos. El identificador del registro es numérico y el SelectItem usa cadenas.
  • null en lugar de "". La API devuelve null para "sin valor" y se pasa tal cual al Select.

Solución

  1. Diagnostica el estado del botón. Con la pantalla de edición abierta, en la consola del navegador:

    const trigger = document.querySelector('[role="combobox"]')
    console.log({
      texto: trigger.textContent,
      esPlaceholder: trigger.hasAttribute("data-placeholder"),
    })

    Si texto está vacío y esPlaceholder es false, el Select tiene un valor que no coincide con ningún elemento. Compara ese valor con la lista de opciones que recibe el componente, incluidos los tipos.

  2. Corrige la consulta de opciones. Es la causa raíz cuando falta la opción guardada: revisa los filtros del endpoint (organización o inquilino, estado, paginación) y asegúrate de que usan el contexto correcto para el registro que se edita.

  3. Asegura que la opción actual esté en la lista. Aunque la consulta sea correcta, una opción desactivada o fuera de la primera página puede faltar. Agrega la opción actual a partir del propio registro (por ejemplo, de la relación que ya incluye la API) o consúltala por su identificador. Pon esta lógica en funciones puras, fáciles de probar:

    export type Option = { value: string; label: string }
    
    /**
     * Garantiza que la opción actual esté en la lista, para que Radix Select
     * pueda mostrar su etiqueta aunque la consulta de opciones no la incluya.
     */
    export function withCurrentOption(
      options: readonly Option[],
      current: Option | null | undefined,
    ): Option[] {
      if (!current) return [...options]
      const exists = options.some((option) => option.value === current.value)
      return exists ? [...options] : [current, ...options]
    }
    
    /**
     * Radix Select compara con === y solo muestra el placeholder con "" o undefined.
     * Normaliza ids numéricos, null y undefined a string.
     */
    export function toSelectValue(id: string | number | null | undefined): string {
      return id === null || id === undefined ? "" : String(id)
    }
  4. Usa los helpers en el componente. El Select recibe siempre una cadena y una lista que contiene la opción actual:

    import * as React from "react"
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
    import { toSelectValue, withCurrentOption, type Option } from "./select-options"
    
    type BranchSelectProps = {
      options: Option[]
      /** Opción asociada al registro, si ya la conoces (p. ej. de la relación incluida). */
      currentOption?: Option | null
      /** Id guardado en el registro; puede ser null. */
      value: string | number | null | undefined
      onValueChange: (value: string) => void
    }
    
    export function BranchSelect({ options, currentOption, value, onValueChange }: BranchSelectProps) {
      const items = React.useMemo(
        () => withCurrentOption(options, currentOption),
        [options, currentOption],
      )
    
      return (
        <Select value={toSelectValue(value)} onValueChange={onValueChange}>
          <SelectTrigger aria-label="Sucursal">
            <SelectValue placeholder="Selecciona una sucursal" />
          </SelectTrigger>
          <SelectContent>
            {items.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      )
    }
  5. No envíes en el guardado lo que el usuario no tocó. En una API de actualización parcial, omitir una clave suele significar "no cambiar" y enviar null suele significar "borrar" (confirma la semántica de la tuya). Envía solo los campos que cambiaron respecto al estado cargado:

    /**
     * Devuelve solo las claves cuyo valor cambió respecto al estado inicial.
     * Útil para un PATCH: lo que no se envía no se borra.
     */
    export function pickChanged<T extends Record<string, unknown>>(
      initial: T,
      current: T,
    ): Partial<T> {
      const changed: Partial<T> = {}
      for (const key of Object.keys(current) as Array<keyof T>) {
        if (!Object.is(initial[key], current[key])) changed[key] = current[key]
      }
      return changed
    }

    Así, un campo que nunca se mostró correctamente no viaja como null y solo se envía branchId: null si el usuario realmente lo borró.

  6. Prueba las funciones puras. Con Vitest, por ejemplo:

    import { describe, expect, it } from "vitest"
    import { pickChanged, toSelectValue, withCurrentOption } from "./select-options"
    
    const options = [
      { value: "1", label: "Sucursal Alfa" },
      { value: "2", label: "Sucursal Beta" },
    ]
    
    describe("withCurrentOption", () => {
      it("agrega la opción actual cuando falta", () => {
        const current = { value: "9", label: "Sucursal Omega" }
        expect(withCurrentOption(options, current)).toEqual([current, ...options])
      })
    
      it("no duplica la opción si ya está", () => {
        expect(withCurrentOption(options, options[1])).toEqual(options)
      })
    })
    
    describe("toSelectValue", () => {
      it("convierte null, undefined y números en string", () => {
        expect(toSelectValue(null)).toBe("")
        expect(toSelectValue(undefined)).toBe("")
        expect(toSelectValue(2)).toBe("2")
        expect(toSelectValue(0)).toBe("0")
      })
    })
    
    describe("pickChanged", () => {
      it("omite lo que no cambió e incluye null solo si se borró", () => {
        const initial = { name: "Pedido", branchId: "7" as string | null }
        expect(pickChanged(initial, { name: "Pedido", branchId: "7" })).toEqual({})
        expect(pickChanged(initial, { name: "Pedido", branchId: null })).toEqual({ branchId: null })
      })
    })

Cómo verificar que quedó resuelto

  1. Abre la edición de un registro cuya opción no esté en la primera consulta (por ejemplo, desactivada): el botón debe mostrar su etiqueta.
  2. Repite el script de diagnóstico: texto debe contener la etiqueta y esPlaceholder debe ser false. En un registro sin valor, texto debe ser el placeholder y esPlaceholder debe ser true.
  3. Guarda sin tocar el campo y revisa en la pestaña de red del navegador que la petición no incluye la clave (o incluye el valor original). Después bórralo a propósito y comprueba que solo entonces viaja null.
  4. Ejecuta las pruebas con npx vitest run.

El componente de ejemplo se comprobó además con Testing Library en jsdom: con el valor "9" y sin la opción, el botón quedó con texto vacío y sin data-placeholder; con la opción actual agregada mostró su etiqueta; y, gracias a toSelectValue, un valor null mostró el placeholder y el número 2 mostró la etiqueta de la opción "2".

Variantes y errores relacionados

defaultValue sin coincidencia

El Select no controlado se comporta igual: con un defaultValue que no coincide con ningún elemento, el botón queda en blanco.

Opciones que llegan después de renderizar

Si las opciones se cargan de forma asíncrona, el botón queda en blanco hasta que los SelectItem existen, y entonces aparece la etiqueta. Si prefieres evitar ese parpadeo, muestra un indicador de carga hasta que lleguen las opciones o incluye la opción actual desde el primer render con withCurrentOption.

Cómo quitar la selección

Según las notas de versión de Radix, desde Select 2.0.0 se puede volver al placeholder usando "" como value; solo supone un cambio incompatible si tenías un elemento con value="". No uses null para eso: en la prueba dejó el botón en blanco.

Con formularios controlados por una librería

Si el valor viene de un campo de formulario que puede ser null, normalízalo en el punto de uso: value={field.value ?? ""}. El tipo de value en Radix es string | undefined: TypeScript marca error si pasas null o un número directamente, pero un valor que llega tipado como any (por ejemplo, desde una respuesta de API sin tipar) pasa sin aviso.

Resumen

  • El Select de Radix solo muestra el placeholder con "" o undefined; si el value no coincide (===) con ningún SelectItem, el botón queda en blanco.
  • null y los números también dejan el botón en blanco: normaliza con toSelectValue.
  • La causa habitual es que la consulta de opciones no incluye la opción guardada; corrige el filtro y agrega la opción actual con withCurrentOption.
  • No envíes null por campos que el usuario no tocó: usa un parche con solo los campos cambiados.
  • Diagnostica con textContent y data-placeholder del botón, y deja la lógica en funciones puras con pruebas.

Referencias

Comentarios