Componentes Controlados vs No Controlados — Elige quién es propietario del estado: el componente o su padre
Receta
// Controlado — el padre es propietario del estado
function ControlledInput() {
const [value, setValue] = useState("");
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// No controlado — el DOM es propietario del estado
function UncontrolledInput() {
const ref = useRef<HTMLInputElement>(null);
const handleSubmit = () => console.log(ref.current?.value);
return <input ref={ref} defaultValue="" />;
}
// Flexible — soporta ambos modos
function FlexibleInput({
value: controlledValue,
defaultValue = "",
onChange,
}: {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) setInternalValue(e.target.value);
onChange?.(e.target.value);
};
return <input value={value} onChange={handleChange} />;
}Cuándo usarlo: Cada componente interactivo debe decidir quién es propietario de su estado. Usa controlado cuando el padre necesita leer o modificar el valor. Usa no controlado para casos más simples donde el componente se gestiona a sí mismo. Construye componentes de biblioteca que soporten ambos modos.
Ejemplo Funcional
import { useState, useRef, useCallback, type ReactNode } from "react";
// Un componente Toggle que soporta uso controlado y no controlado
interface ToggleProps {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
children: ReactNode;
}
function Toggle({
pressed: controlledPressed,
defaultPressed = false,
onPressedChange,
children,
}: ToggleProps) {
const [internalPressed, setInternalPressed] = useState(defaultPressed);
const isControlled = controlledPressed !== undefined;
const pressed = isControlled ? controlledPressed : internalPressed;
const handleClick = useCallback(() => {
const next = !pressed;
if (!isControlled) {
setInternalPressed(next);
}
onPressedChange?.(next);
}, [pressed, isControlled, onPressedChange]);
return (
<button
type="button"
role="switch"
aria-checked={pressed}
onClick={handleClick}
className={`px-4 py-2 rounded-full transition-colors ${
pressed
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700"
}`}
>
{children}
</button>
);
}
// --- Ejemplos de uso ---
// No controlado — el componente gestiona su propio estado
function SimpleToggle() {
return (
<Toggle
defaultPressed={false}
onPressedChange={(p) => console.log("Alternado:", p)}
>
Modo Oscuro
</Toggle>
);
}
// Controlado — el padre es propietario y puede sobreescribir el estado
function SyncedToggles() {
const [enabled, setEnabled] = useState(false);
return (
<div className="flex gap-4">
<Toggle pressed={enabled} onPressedChange={setEnabled}>
Alternar A
</Toggle>
<Toggle pressed={enabled} onPressedChange={setEnabled}>
Alternar B
</Toggle>
<p>Ambos están: {enabled ? "ENCENDIDOS" : "APAGADOS"}</p>
</div>
);
}Lo que esto demuestra:
- Un único componente que funciona en ambos modos controlado y no controlado
- La comprobación
isControlleddetermina qué fuente de estado usar - El modo no controlado usa
defaultPressedpara el estado inicial - El modo controlado permite al padre sincronizar múltiples instancias
Análisis Profundo
Cómo Funciona
- Los componentes controlados reciben su valor actual a través de props y notifican cambios mediante callbacks. El padre es la única fuente de verdad.
- Los componentes no controlados gestionan su propio estado internamente y opcionalmente notifican al padre mediante callbacks.
- La convención
default*prop (p. ej.,defaultValue,defaultChecked,defaultPressed) señala el estado inicial no controlado. - Un componente detecta su modo verificando si la prop controlada es
undefined. - React 19 form actions con
useActionStatepueden simplificar patrones de formularios, pero la distinción controlada/no controlada aún se aplica.
Parámetros y Valores Devueltos
| Convención de Prop | Modo | Propósito |
|---|---|---|
value | Controlado | Valor actual, establecido por el padre |
defaultValue | No Controlado | Valor inicial, el componente gestiona después |
onChange | Ambos | Callback que notifica al padre de cambios |
ref | No Controlado | Acceso imperativo para leer el valor del DOM |
Variaciones
Hook useControllableState — extrae el patrón en un hook reutilizable:
function useControllableState<T>({
value: controlledValue,
defaultValue,
onChange,
}: {
value?: T;
defaultValue: T;
onChange?: (value: T) => void;
}): [T, (next: T) => void] {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const setValue = useCallback(
(next: T) => {
if (!isControlled) setInternalValue(next);
onChange?.(next);
},
[isControlled, onChange]
);
return [value, setValue];
}
// Uso dentro de cualquier componente
function Slider({ value, defaultValue = 0, onChange, min = 0, max = 100 }: SliderProps) {
const [current, setCurrent] = useControllableState({
value,
defaultValue,
onChange,
});
// ... renderiza con `current` y `setCurrent`
}React 19 form actions — formularios no controlados con acciones de servidor:
function ContactForm() {
async function submitAction(formData: FormData) {
"use server";
const email = formData.get("email") as string;
await sendEmail(email);
}
return (
<form action={submitAction}>
<input name="email" type="email" defaultValue="" />
<button type="submit">Enviar</button>
</form>
);
}Notas de TypeScript
- Usa tipado opcional (
value?: T) para la prop controlada para queundefinedseñale modo no controlado. - Haz
defaultValueobligatorio cuandovalueno se proporciona, o dale un valor por defecto razonable. - Considera un tipo de unión discriminado para cumplimiento estricto de una u otra, aunque reduce la flexibilidad.
Gotchas
-
Cambiar entre controlado y no controlado — Cambiar
valuedeundefineda un valor definido (o viceversa) durante el ciclo de vida del componente causa bugs. React advierte sobre esto. Solución: Decide el modo en el montaje y adhiérete a él. Usa un ref para rastrear el modo inicial. -
Entrada controlada con actualización de estado retrasada — Si el manejador
onChangeactualiza el estado de forma asincrónica (p. ej., con debounce), la entrada parece congelarse. Solución: Actualiza el estado local inmediatamente y aplica debounce solo al efecto secundario, no a la actualización de estado. -
onChange faltante en componente controlado — Proporcionar
valuesinonChangecrea una entrada de solo lectura. React advierte. Solución: Siempre emparejavaluecononChange, o usareadOnlysi es intencional. -
defaultValue cambia después del montaje — Cambiar
defaultValuedespués del primer renderizado no tiene efecto. Solución: Usa una propkeypara remontar el componente si el valor inicial necesita ser restablecido.
Alternativas
| Enfoque | Compensación |
|---|---|
| Controlado | Control completo del padre; requiere gestión de estado en el padre |
| No Controlado | Más simple; más difícil para el padre leer o sincronizar estado |
| Flexible (ambos modos) | Mejor para bibliotecas; más complejidad de implementación |
| React 19 form actions | Excelente para formularios; no controlado con manejo del lado del servidor |
| Biblioteca de gestión de estado | Zustand o Redux pueden actuar como el controlador para formularios complejos |
FAQs
¿Cuál es la diferencia clave entre componentes controlados y no controlados?
- Controlado: el padre es propietario del estado a través de props (
value+onChange). El padre es la única fuente de verdad. - No Controlado: el componente gestiona su propio estado internamente. El padre puede leerlo a través de
refo recibir notificaciones a través de callbacks. - La convención de prop
default*señala el estado inicial no controlado.
¿Cómo detecta un componente si está en modo controlado o no controlado?
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;- Verifica si la prop controlada (
value) esundefined. - Si está definida, usa el valor controlado. Si es
undefined, usa el estado interno.
¿Qué es el hook useControllableState y cuándo deberías usarlo?
- Es un hook reutilizable que encapsula la lógica de detección controlada/no controlada.
- Úsalo dentro de cualquier componente que necesite soportar ambos modos para evitar duplicar el patrón.
- Devuelve
[value, setValue]y maneja el estado interno, passthrough controlado, y callbacksonChange.
¿Cómo se relacionan React 19 form actions con controlado vs no controlado?
<form action={submitAction}>
<input name="email" type="email" defaultValue="" />
<button type="submit">Enviar</button>
</form>- Las acciones de formulario usan entradas no controladas con
defaultValuey leen valores desdeFormData. - La distinción controlada/no controlada aún se aplica; las acciones de formulario favorecen el enfoque no controlado.
Gotcha: ¿Qué sucede si cambias entre modo controlado y no controlado durante el ciclo de vida del componente?
- Cambiar
valuedeundefineda un valor definido (o viceversa) causa bugs y advertencias de React. - El modo debe ser decidido en el montaje y permanecer consistente.
- Solución: usa un ref para rastrear el modo inicial y advierte si cambia.
Gotcha: ¿Por qué una entrada controlada con onChange debounced parece congelarse?
- Si el manejador
onChangeactualiza el estado de forma asincrónica (p. ej., con debounce), la propvaluede la entrada no se actualiza inmediatamente. - React re-renderiza con el valor obsoleto, haciendo que la entrada parezca congelada.
- Solución: actualiza el estado local inmediatamente y aplica debounce solo al efecto secundario, no a la actualización de estado.
¿Cómo escribes un componente flexible que soporta ambos modos controlado y no controlado en TypeScript?
interface ToggleProps {
pressed?: boolean; // controlado
defaultPressed?: boolean; // no controlado
onPressedChange?: (pressed: boolean) => void;
children: ReactNode;
}- Usa tipado opcional (
pressed?: boolean) para queundefinedseñale modo no controlado. - Da a
defaultPressedun valor por defecto razonable.
¿Puedes usar un tipo de unión discriminado para forzar estrictamente props controlados o no controlados en TypeScript?
- Sí, puedes definir ramas de tipo separadas para props controlados y no controlados usando una unión discriminada.
- Sin embargo, esto reduce la flexibilidad porque los consumidores no pueden omitir tanto
valuecomodefaultValue. - La mayoría de las bibliotecas prefieren el enfoque flexible con props opcionales para ambos modos.
¿Por qué cambiar defaultValue después del montaje no tiene efecto?
defaultValuesolo establece el estado inicial en el primer renderizado.- React ignora cambios a
defaultValueen renderizados posteriores porque el estado interno ya está inicializado. - Solución: usa una prop
keypara remontar el componente si el valor inicial necesita ser restablecido.
¿Qué sucede si proporcionas value sin onChange en una entrada controlada?
- La entrada se vuelve de solo lectura porque React cumple la prop
valueen cada renderizado. - React advierte en la consola sobre un manejador
onChangefaltante. - Solución: siempre empareja
valuecononChange, o añade explícitamente el atributoreadOnlysi es intencional.
¿Cuándo deberías usar un componente controlado vs un componente no controlado?
- Usa controlado cuando el padre necesita leer, validar, o sincronizar el valor a través de múltiples componentes.
- Usa no controlado cuando el componente puede gestionar a sí mismo y el padre solo necesita el valor en el envío.
- Construye componentes de biblioteca que soporten ambos modos para máxima flexibilidad.
Relacionado
- Compound Components — Los componentes compuestos a menudo necesitan soporte controlado/no controlado
- State Machines — Formaliza transiciones de estado para componentes controlados complejos
- Context Patterns — Comparte estado controlado a través de árboles de componentes compuestos