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
valuees la cadena vacía oundefined. - El texto del valor no sale del
valueen sí, sino delSelectItemque coincide: elSelectItemTextdel elemento seleccionado se envía al nodo delSelectValue. Un elemento está seleccionado cuando suvaluees estrictamente igual (===) al delSelect.
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 Select | Elementos disponibles | Lo que se ve |
|---|---|---|
"2" | incluyen "2" | la etiqueta del elemento |
"zzz" | no incluyen "zzz" | en blanco, sin data-placeholder |
"" o undefined | cualquiera | el placeholder |
null | cualquiera | en blanco, no el placeholder |
2 (número) | incluyen "2" | en blanco |
"2" | llegan después de renderizar | en 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
SelectItemusa cadenas. nullen lugar de"". La API devuelvenullpara "sin valor" y se pasa tal cual alSelect.
Solución
-
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
textoestá vacío yesPlaceholderesfalse, elSelecttiene un valor que no coincide con ningún elemento. Compara ese valor con la lista de opciones que recibe el componente, incluidos los tipos. -
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.
-
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) } -
Usa los helpers en el componente. El
Selectrecibe 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> ) } -
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
nullsuele 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
nully solo se envíabranchId: nullsi el usuario realmente lo borró. -
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
- 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.
- Repite el script de diagnóstico:
textodebe contener la etiqueta yesPlaceholderdebe serfalse. En un registro sin valor,textodebe ser el placeholder yesPlaceholderdebe sertrue. - 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. - 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
Selectde Radix solo muestra el placeholder con""oundefined; si elvalueno coincide (===) con ningúnSelectItem, el botón queda en blanco. nully los números también dejan el botón en blanco: normaliza contoSelectValue.- 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
nullpor campos que el usuario no tocó: usa un parche con solo los campos cambiados. - Diagnostica con
textContentydata-placeholderdel botón, y deja la lógica en funciones puras con pruebas.
Comentarios
Publicar un comentario