Inicio / Artículos / Una línea de base predecible para React con TypeScript, Zustand y servicios tipados

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.

1207 palabras

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.
  • Zustand es una biblioteca estatal pequeña con muy poca formalidad: el almacén funciona como un gancho, el modelo de datos está formado por objetos y funciones sencillas, y los componentes solo escuchan las secciones de datos que leen.
  • 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.
  • Proteja los flujos asíncronos con verificaciones de cancelación y actualidad antes de que las condiciones de competencia lleguen a los usuarios.
  • Lecturas relacionadas