Una línea de base predecible para React con TypeScript, Zustand y servicios tipados
Un esqueleto pequeño de React, TypeScript y Zustand que separa las stores, los servicios API tipados y los componentes, además de convenciones para la estructura, el estado asíncrono y las pruebas.
La primera pantalla de un proyecto nuevo es muy sencilla, pero las decisiones que la sustentan —dónde se almacena el estado, cómo se obtienen los datos y con qué rigor se define el tipo de las variables— determinan todo lo que viene después. Una pequeña “Hello App” es el lugar ideal para definirlas. Esta guía crea una aplicación con React, TypeScript y Zustand, explica cuál es la función de cada componente del esqueleto y convierte las convenciones subyacentes en reglas que puedes aplicar a medida que el códigobase crece.
¿Por qué React, TypeScript y Zustand juntos?
Cada herramienta aborda una preocupación diferente:
- React ofrece una interfaz de usuario declarativa, un ecosistema maduro y herramientas potentes; además, la composición permite que los componentes sean fáciles de leer.
- TypeScript detecta errores en tiempo de compilación, hace que el refactoring sea más seguro y convierte las propiedades de los componentes y la estructura del estado en contratos autoexplicativos.
Comience con esos tres elementos más un enrutador, y añada una biblioteca solo cuando surja una necesidad concreta.
El esqueleto: almacén, servicio y componente
El ejemplo a continuación se muestra como una sola lista, pero en realidad representa tres archivos, cada uno con una tarea específica. store/counterStore.ts define un almacén de Zustand tipado que contiene un count, una bandera loading, una acción síncrona increment y una acción asíncrona loadInitial. services/counterApi.ts envuelve la llamada HTTP en una función tipada que lanza un error ante respuestas no satisfactorias. App.tsx lee los valores individuales a través de selectores y activa la carga inicial en un efecto.
Observe tres cosas. La tienda nunca llama directamente a fetch; delega esa tarea en el servicio, de modo que la capa de red puede ser reemplazada o simulada de forma independiente. El bloque finally garantiza que loading se reinicie incluso cuando la solicitud falla. Además, el componente se suscribe a cada campo con su propio selector, por lo que vuelve a renderizarse solo cuando cambia un valor que realmente utiliza.
// store/counterStore.ts
import { create } from "zustand"
type CounterState = {
count: number
loading: boolean
increment: () => void
loadInitial: () => Promise<void>
}
export const useCounter = create<CounterState>((set, get) => ({
count: 0,
loading: false,
increment: () => set({ count: get().count + 1 }),
loadInitial: async () => {
set({ loading: true })
try {
const value = await fetchInitialCount()
set({ count: value })
} finally {
set({ loading: false })
}
},
}))
// services/counterApi.ts
export type CounterResponse = { value: number }
export async function fetchInitialCount(): Promise<number> {
const res = await fetch("/api/counter")
if (!res.ok) throw new Error("Failed to load")
const data = (await res.json()) as CounterResponse
return data.value
}
// App.tsx
import React, { useEffect } from "react"
import { useCounter } from "./store/counterStore"
export default function App() {
const count = useCounter(s => s.count)
const loading = useCounter(s => s.loading)
const increment = useCounter(s => s.increment)
const loadInitial = useCounter(s => s.loadInitial)
useEffect(() => {
void loadInitial()
}, [loadInitial])
return (
<main>
<h1>Hello App</h1>
<p>{loading ? "Loading..." : `Count: ${count}`}</p>
<button onClick={increment} disabled={loading}>
Increment
</button>
</main>
)
}
Hay algunos detalles que conviene ajustar antes de copiar esto en un proyecto real. Como archivos separados, el almacén necesita una importación explícita de fetchInitialCount desde el módulo de servicio. increment lee el valor actual con get(); la forma funcional set((s) => ({ count: s.count + 1 })) expresa la misma intención y es el idioma más común. Finalmente, una solicitud fallida actualmente simplemente termina la carga y lanza un error nuevamente, lo que deja una rechazo sin manejar porque el efecto descarta la promesa con void; agregar un campo error al almacén y capturar el error allí permite que la interfaz muestre información precisa.
Estructura que se mantiene clara a medida que crece
Clasifique los códigos por funcionalidad en lugar de por tipo de archivo. Un folder por funcionalidad que contenga su almacén, tipos y interfaz de usuario es más fácil de navegar que los directorios de nivel superior components, utils y services a los que debe afectar cada cambio. Las utilidades compartidas y el sistema de diseño tienen sus propios módulos. Para una comparación más detallada de los diseños, consulte elegir una estructura de folders en React.
TypeScript como capa de contrato
Trate los tipos como parte de la API pública de cada módulo. Exporte los tipos que necesiten los consumidores y mantenga los internos como privados. Active strict (que incluye noImplicitAny) y las configuraciones estrictas de JSX desde el primer día; implementar la estrictitud posteriormente es mucho más problemático. Utilice tipos auxiliares como Pick, Omit y ReturnType para que los tipos derivados permanezcan sincronizados, y escriba sus selectores con tipos.
Usar Zustand sin complicaciones
Divida el estado en almacenes pequeños por dominio, por ejemplo authStore y todosStore, cada uno creado con create. Mantenga los selectores específicos: seleccionar un único campo evita la recarga cuando cambian campos no relacionados, mientras que seleccionar un objeto recién creado en cada llamada puede causar recargas adicionales a menos que utilice una ayuda de igualdad superficial. Zustand no impone reducers, por lo que las actualizaciones permanecen breves y predecibles siempre y cuando genere nuevos valores en lugar de mutar el estado.
Patrones de componentes y formularios
Separar los contenedores de los componentes presentacionales. Los contenedores se comunican con las bases de datos y gestionan la lógica; los componentes presentacionales reciben propiedades definidas, permanecen puros y son fáciles de probar. Para formularios simples, son suficientes los campos controlados; para los complejos, una biblioteca ligera como react-hook-form combinada con un esquema que tenga en cuenta TypeScript mantiene la validación y los tipos alineados. Utilice React.memo, useMemo y useCallback solo cuando el análisis de rendimiento demuestre que hay beneficios.
Trabajo asíncrono y efectos secundarios
Coloca cada llamada a la API en un servicio tipado, y permite que las tiendas llamen a los servicios manteniendo únicamente el estado necesario para la interfaz de usuario. Registra el estado de las solicitudes, el proceso de carga, los errores y los éxitos en la tienda para que la interfaz refleje siempre lo que realmente está sucediendo. Cancela las solicitudes antiguas con AbortController, y guarda un ID de solicitud o un marcador de actualidad en la tienda para que una respuesta más antigua no pueda sobrescribir a una más reciente.
Pruebas y calidad del código
Las pruebas unitarias para la lógica empresarial central y los selectores de tienda son económicas y rinden rápidamente frutos. Combina ESLint con reglas compatibles con TypeScript y Prettier, y ejecútalas automáticamente en un gancho pre-commit. Crea datos para pruebas y Storybook con fábricas de fixtures tipadas, de modo que se genere un error en tiempo de compilación cuando cambie un modelo.
Crecimiento sin reescrituras
Añada capacidades verticalmente: una nueva función implica una nueva carpeta, almacenamiento y ruta, mientras que la localización y el tema se encuentran en módulos separados. Cuando cambia una API o un modelo de datos, permita que el compilador indique cada contrato roto. Introduzca el caché, la normalización y las actualizaciones optimistas solo cuando lo exijan las necesidades reales; si el estado del servidor comienza a dominar el almacenamiento, una biblioteca dedicada a la obtención de datos suele ser un lugar más adecuado para ello que Zustand.
Puntos clave
- Mantenga los almacenamientos, servicios y componentes en módulos separados y de propósito único, incluso en la aplicación más pequeña.
- Lea el estado mediante selectores específicos y cargue explícitamente el modelo así como el estado de errores.
- Habilite TypeScript estricto desde temprano y permita que los tipos documenten y apliquen los límites entre módulos.
- Organice por funcionalidad, añada dependencias solo cuando sea necesario y optimice después de realizar mediciones.
Lecturas relacionadas
- Construyendo un módulo React basado en características: Posts CRUD con TanStack Query — Reestructure una aplicación React por características en lugar de tipo de archivo, conecte la funcionalidad completa de posts CRUD con TanStack Query y axios, y observe dónde deja de ser escalable este patrón.
- Cómo React-Redux decide volver a renderizar: Selectores, igualdad y gancho tipados — Una guía de estudio sobre los componentes internos de React-Redux: Provider, igualdad de useSelector, selectores memorizados, connect(), gancho tipados con withTypes(), y casos límite poco comunes.