Haciendo imposibles los estados imposibles en React
Traducido por IA desde el original en inglés.
If it's possible to represent states that should be impossible… please, at least consider making the possible impossible.
Considera la siguiente situación, enfocándonos en cómo está estructurado el estado del componente:
function TaskList() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [isLoading, setLoading] = React.useState(true);
const [error, setError] = React.useState<unknown>(null);
React.useEffect(function loadTasks() {
fetchApi<Task[]>('/tasks')
.then((tasks) => {
setTasks(tasks);
setLoading(false);
setError(null);
})
.catch((error) => {
setError(error);
setLoading(false);
setTasks([]);
});
}, []);
if (error) {
return <p>Error loading tasks</p>;
}
if (isLoading) {
return <p>Loading…</p>;
}
if (tasks.length === 0) {
return <p>There are no tasks</p>;
}
return (
<ul>
{tasks.map((task) => (
<li>{task.content}</li>
))}
</ul>
);
}
En particular, fíjate en cómo debemos actualizar varias piezas de estado al mismo tiempo.
setTasks(tasks);
setLoading(false);
setError(null);
Incluso hay otro posible problema. La forma en que está estructurado el estado puede ser ambigua al interpretarlo, sobre todo por los flags isLoading y error. ¿Qué pasa si isLoading y error son verdaderos a la vez? ¿Estamos cargando datos o recibimos un error?
Podrías pensar que no importa porque el componente se asegura de setear/desetear todos los flags en los flujos esperados. Pero puede generar confusión si refactorizamos algo y olvidamos cubrir algún caso.
¿Qué pasaría si diseñáramos el estado de este componente para que fuera realmente inequívoco en todo momento, por diseño? Veamos cómo.
Una sola pieza de estado compuesta
Podemos aprovechar los tipos unión de TypeScript, y tener disponible solo la data que tiene sentido en cada estado.
type State =
| { status: 'loading' }
| { status: 'success'; tasks: Task[] }
| { status: 'error'; error: unknown };
function TaskList() {
const [state, setState] = React.useState<State>({
status: 'loading',
});
React.useEffect(function loadTasks() {
fetchApi<Task[]>('/tasks')
.then((tasks) => {
setState({ status: 'success', tasks });
})
.catch((error) => {
setState({ status: 'error', error });
});
}, []);
if (state.status === 'error') {
return <p>Error loading tasks</p>;
}
if (state.status === 'loading') {
return <p>Loading…</p>;
}
if (state.tasks.length === 0) {
return <p>There are no tasks</p>;
}
return (
<ul>
{state.tasks.map((task) => (
<li>{task.content}</li>
))}
</ul>
);
}
Fíjate cómo solo tenemos tareas disponibles si la carga fue exitosa, o el objeto de error si falló. Además, esto asegura desde el inicio que cubrimos todos los casos. En un componente como este, no podrías escribir el happy path (el return final) si antes no cubres error y loading. TypeScript ni siquiera te deja acceder al arreglo de tareas.
Otras alternativas
Esta no es toda la historia. La transición al patrón anterior abre la puerta a otras formas de manejo de estado que suelen ser el siguiente paso en casos más complejos.
useReducer
La primera es pasar a useReducer. No creo que el caso de arriba lo necesite, pero por el ejercicio, veamos cómo quedaría:
type State =
| { status: 'loading' }
| { status: 'success', tasks: Task[] }
| { status: 'error', error: unknown }
type Action
| { type: 'success', tasks: Task[] }
| { type: 'error', error: unknown }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'success':
return { status: 'success', tasks: action.tasks }
case 'error':
return { status: 'error', error: action.error }
default:
return state
}
}
function TaskList() {
const [state, dispatch] = React.useReducer(reducer, { status: 'loading' })
React.useEffect(function loadTasks() {
fetchApi<Task[]>('/tasks')
.then((tasks) => {
dispatch({ type: 'success', tasks })
})
.catch((error) => {
dispatch({ type: 'error', error })
})
}, [])
// Code from this point on remains the same as before…
}
Probablemente ya ves por qué me parece overkill para este ejemplo:
- Las llamadas a
dispatchson muy parecidas a cuando usábamossetState, pero la complejidad extra (reducer+ tipoAction) no se justifica. - El reducer es demasiado básico; prácticamente solo reenvía argumentos al nuevo estado.
Aun así, sirve para ilustrar casos donde sí vale la pena. Si empiezas a hacer cálculos de estado más complejos en setState, o necesitas partes del estado previo para construir el nuevo, entonces un reducer se vuelve más razonable.
Máquinas de estado
En el fondo, es lo que venimos haciendo en todo este post: máquinas de estado. Son excelentes para hacer imposibles los estados imposibles. Así que esta sección agrega poco nuevo. No entraré en detalle porque el tema da para libros enteros y no soy experto en ello.
Pero conviene conocerlo. Para casos más complejos, hay patrones y librerías que modelan mucho mejor máquinas de estado, acciones de transición, validaciones previas, mejor manejo de errores, etc. Incluso los efectos secundarios suelen manejarse mejor con librerías dedicadas que con useEffect y dependencias de estado.
Si quieres empezar, puedes mirar xstate, una de las librerías de state machines más populares.