Tipando State
Receta
Tipifica correctamente el state de tu componente con useState y useReducer. Maneja valores simples, objetos complejos, state nullable, y reductores de unión discriminada.
Ejemplo Funcional
// useState simple - el tipo se infiere
const [count, setCount] = useState(0); // number
const [name, setName] = useState(""); // string
const [enabled, setEnabled] = useState(false); // boolean// Tipificación explícita para state complejo o nullable
type User = {
id: string;
name: string;
email: string;
};
const [user, setUser] = useState<User | null>(null);
// Más tarde...
if (user) {
console.log(user.name); // TypeScript sabe que user no es null aquí
}// useReducer con acciones de unión discriminada
type CounterState = {
count: number;
lastAction: string;
};
type CounterAction =
| { type: "increment"; payload: number }
| { type: "decrement"; payload: number }
| { type: "reset" };
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + action.payload, lastAction: "increment" };
case "decrement":
return { count: state.count - action.payload, lastAction: "decrement" };
case "reset":
return { count: 0, lastAction: "reset" };
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0, lastAction: "none" });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment", payload: 1 })}>+1</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}Profundización
Cómo Funciona
useStateinfiere el tipo del valor inicial.useState(0)te da[number, Dispatch<SetStateAction<number>>].- Cuando el valor inicial no representa todos los estados posibles (por ejemplo, comenzando como
nullpero eventualmente manteniendo un objeto), debes proporcionar un parámetro de tipo explícito:useState<User | null>(null). useReducerinfiere los tipos de state y action de la firma de la función reductora. Definir el reductor con tipos de parámetro explícitos te da seguridad de tipos completa en el cuerpo del reductor y en las llamadasdispatch.- Las acciones de unión discriminada (acciones con un campo
typeliteral) permiten que TypeScript estreche la action dentro de cada ramacase, dándote acceso a campospayloadespecíficos de la rama.
Variaciones
Inicializador perezoso:
const [data, setData] = useState<Map<string, User>>(() => new Map());State con una tupla:
const [coords, setCoords] = useState<[number, number]>([0, 0]);useReducer con Immer:
import { useImmerReducer } from "use-immer";
function reducer(draft: CounterState, action: CounterAction) {
switch (action.type) {
case "increment":
draft.count += action.payload;
draft.lastAction = "increment";
break;
case "reset":
draft.count = 0;
draft.lastAction = "reset";
break;
}
}
const [state, dispatch] = useImmerReducer(reducer, { count: 0, lastAction: "none" });Notas de TypeScript
SetStateAction<T>esT | ((prev: T) => T). Esto es por qué tantosetCount(5)comosetCount(prev => prev + 1)funcionan.- Para
useReducer, el tipo de retorno del reductor debe coincidir con el tipo del state. TypeScript lo aplica automáticamente cuando anotas los parámetros del reductor. - Evita las aserciones
ascon state. Si TypeScript se queja, usualmente significa que tus tipos necesitan ajuste, no un cast.
Trampas
- Usar
useState<User>()sin un valor inicial te daUser | undefined, noUser. Siempre proporciona un valor inicial o tipifica explícitamente comoUser | undefined. - Pasar un objeto literal a
setUsersin propagar el state anterior reemplaza el objeto completo. TypeScript capturará campos requeridos faltantes, lo cual es en realidad útil. - Tipificar state como
anyderrota el propósito. Incluso para formas dinámicas, usaRecord<string, unknown>o un tipo adecuado. - Olvidar manejar el caso
nullen state nullable lleva a errores en tiempo de ejecución que TypeScript intenta prevenir mediante verificaciones estrictas de null.
Alternativas
| Enfoque | Pros | Contras |
|---|---|---|
useState con inferencia | Cero boilerplate para valores simples | No puede expresar estados iniciales nullable o union |
useState<T> genérico explícito | Control completo sobre el tipo de state | Ligeramente más verboso |
useReducer | Transiciones de state predecibles, excelente para state complejo | Más boilerplate que useState |
| Zustand store | State compartido con inferencia de TypeScript | Dependencia externa |
useActionState (React 19) | Gestión de state de formulario integrada | Limitado a patrones de formulario/action |
Preguntas Frecuentes
¿Cuándo infiere useState el tipo automáticamente?
- Cuando proporcionas un valor inicial:
useState(0)infierenumber,useState("")infierestring. - Si el valor inicial representa completamente todos los estados posibles, no se necesita genérico explícito.
¿Cuándo debería proporcionar un parámetro de tipo explícito a useState?
- Cuando el valor inicial no cubre todos los estados posibles, por ejemplo,
useState<User | null>(null). - También para tipos complejos como tuplas:
useState<[number, number]>([0, 0]).
¿Qué es SetStateAction<T> y por qué puedo pasar una función a un setter?
SetStateAction<T>se define comoT | ((prev: T) => T).- Esto es por qué tanto
setCount(5)comosetCount(prev => prev + 1)son válidos.
¿Cómo funcionan las acciones de unión discriminada con useReducer?
- Define actions con un campo
typeliteral:{ type: "increment"; payload: number } | { type: "reset" }. - TypeScript estrecha la action dentro de cada caso
switch, dando acceso a campos específicos de la rama comopayload.
¿Qué es un inicializador perezoso para useState?
const [data, setData] = useState<Map<string, User>>(() => new Map());- La función se llama solo en el primer renderizado, evitando recrear objetos costosos en cada renderizado.
Trampa: ¿Qué sucede si llamo useState<User>() sin un valor inicial?
- El tipo de state se convierte en
User | undefined, noUser. - Siempre proporciona un valor inicial o tipifica explícitamente como
User | undefined.
Trampa: ¿Mescla setUser(newPartialData) con el state anterior?
- No. Los setters de
useStatereemplazan el valor completo. A diferencia delsetStatedel componente de clase, no hay fusión. - Debes propagar el state anterior tú mismo:
setUser(prev => ({ ...prev, ...newPartialData })). - TypeScript capturará campos requeridos faltantes si olvidas propagar.
¿Por qué debería evitar tipificar state como any?
- Derrota el propósito de TypeScript y elimina toda la seguridad en tiempo de compilación.
- Para formas dinámicas, prefiere
Record<string, unknown>o define un tipo adecuado.
¿Cómo aplica useReducer que el tipo de retorno del reductor coincida con el tipo de state?
- TypeScript verifica que el tipo de retorno de tu función reductora coincida con el tipo de parámetro de state.
- Si una rama
caseretorna una forma que le falta un campo requerido, obtienes un error en tiempo de compilación.
¿Puedo usar Immer con useReducer y aún obtener seguridad de tipos?
import { useImmerReducer } from "use-immer";
function reducer(draft: CounterState, action: CounterAction) {
switch (action.type) {
case "increment":
draft.count += action.payload;
break;
}
}- Sí.
useImmerReducerinfiere tipos de la firma del reductor al igual queuseReducer.
¿Cuál es la diferencia entre useReducer y Zustand para gestión de state?
useReducerestá integrado, limitado a un componente, y tiene cero dependencias.- Zustand proporciona state compartido entre componentes con inferencia de TypeScript pero requiere un paquete externo.
¿Cómo manejo state nullable de forma segura?
- Tipifícalo explícitamente:
useState<User | null>(null). - Usa una verificación de null antes de acceder a propiedades:
if (user) { user.name }. - Las verificaciones estrictas de null en TypeScript marcarán el acceso desprotegido.