Inicio / Artículos / Estado en la práctica: tiendas pequeñas, selectores precisos, rastreador de plantas

Estado en la práctica: tiendas pequeñas, selectores precisos, rastreador de plantas

Reemplace la perforación de propiedades y la ceremonia Redux por create(), selectores, acciones asíncronas, middleware persist/devtools, slices y una aplicación de planta de hidratación.

5694 palabras

Un recorrido adecuado para principiantes por Zustand: la biblioteca de estado de React con decenas de millones de descargas semanales — además de un ejemplo de cuidado de plantas desarrollado de principio a fin.

El momento en que alguien finalmente te dice que existe una forma mejor

Antes de Zustand: qué hace realmente React

Las páginas web comienzan como etiquetas HTML — div, button, h1. Editar a mano ese marcado para un producto complejo es doloroso: cada cambio de datos implica buscar elementos y reescribirlos. ¿Cambian los detalles de inicio de sesión? Hay que corregir una docena de lugares. ¿El carrito crece? Habrá que corregir otros doce.

React invierte este modelo. Los componentes son funciones que describen la interfaz de usuario a partir de los datos actuales; la biblioteca decide qué modificar en el DOM. Los desarrolladores describen; React actualiza.

Un componente pequeño se ve así:

function WelcomeBanner() {
  return <h1>Hello, stranger</h1>
}

Esa función devuelve JSX: una sintaxis con forma de HTML que React compila, no HTML literal.

Glosario rápido para las secciones siguientes:

  • JavaScript — el lenguaje que está detrás de console.log y const x = 5.
  • npm — el instalador de paquetes. npm install zustand descarga Zustand en el proyecto.
  • Hooks — funciones cuyos nombres comienzan con use (useState, useEffect, hooks personalizados para almacenes). Son la forma moderna en React de manejar datos y efectos.

Si comprendes estas tres ideas, lo demás de esta guía será más fácil de asimilar.

React es el chef; tú le entregas la receta y los ingredientes.

Qué significa realmente “state”

El estado es cualquier cosa que la interfaz de usuario debe recordar: si el usuario está conectado o no, el tamaño del carrito, si la barra lateral está abierta, la URL del avatar, el texto en un cuadro de búsqueda. Las variables simples no actualizan automáticamente la pantalla. Los hooks de React almacenan valores y programan una nueva renderización cuando esos valores cambian.

El hook más simple es useState:

import { useState } from 'react'
function Counter() {
  const [count, setCount] = useState(0)
  // Creates a state variable called count, starting at 0.
  // setCount is the only way to change it.
  // Every time setCount runs, React re-renders this component.
  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  )
}

El estado local es útil cuando un único componente controla el valor. Los problemas surgen cuando componentes separados deben compartir la misma información — formulario de inicio de sesión, avatar en el encabezado, saludo en el panel de control, opción de cierre de sesión en los ajustes — sin estar relacionados directamente en la estructura del árbol de componentes.

Las llamadas independientes a useState no se comunican entre sí. Compartir información entre componentes alejados es el verdadero problema.

Estados separados, incapaces de interactuar entre sí. El problema resumido en una sola imagen.

El sufrimiento por la transmisión de propiedades

La primera solución de React es “elevar el estado”: colocar los datos compartidos en el ancestro común más cercano y pasarlos hacia abajo como props (argumentos de función).

Eso funciona para árboles pequeños. Falla cuando user debe recorrer App → Layout → MainContent → Dashboard → Header → ProfilePic. Las capas intermedias nunca utilizan los datos; solo los transmiten. El tema, el carrito y los tokens de autenticación añaden otro paso adicional a través de padres indiferentes. Los refactores dañan archivos no relacionados.

Context estaba diseñado para detener esta cadena de transmisión. Un Provider envuelve el árbol; los hijos llaman a useContext. El problema es que los consumidores suelen volver a renderizarse cada vez que cambia cualquier parte del valor del contexto, lo cual es problemático cuando el estado se actualiza con frecuencia.

Redux (2015) ofreció un estado externo predecible y selectores que volvían a renderizar solo lo que había cambiado; además, introdujo muchas formalidades de las que los equipos se cansaron de mantener.

Llega el oso

Zustand proviene del colectivo Poimandres de Paul Henschel (también responsable de React Three Fiber y Jotai). El nombre significa “estado” en alemán. Tiene como mascota a un oso. El proyecto cuenta con decenas de miles de estrellas en GitHub y aproximadamente veinte millones de descargas semanales desde npm, más que Redux clásico junto con Redux Toolkit en muchas semanas recientes.

Toda la API central consiste en una sola función, create. Se pasa una definición del estado y las acciones; se recibe un gancho de React. No hay Provider. Ningunas constantes para tipos de acciones. Ningunos archivos separados para los reducers. Ejemplo:

import { create } from 'zustand'
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

Cualquier componente puede llamar a ese gancho. Sin carreras de relevo.

Cada componente habla directamente con el almacén. No se necesita ninguna carrera de relevo.

Modelo mental: la tienda es un mensaje de chat grupal fijado al que todos pueden leer y editar. Redux se parece más a un tribunal: se presenta una solicitud (acción), se espera el veredicto del reductor y se accede al contenido a través de una ventana controlada. Redux logra la previsibilidad mediante la burocracia; Zustand confía en actualizaciones disciplinadas con menos trámites.

La primera tienda real

Crea una aplicación Vite React y instala la biblioteca:

npm create vite@latest my-first-zustand -- --template react
cd my-first-zustand
npm install
npm install zustand
npm run dev

Crea useCounterStore.js:

// useCounterStore.js
import { create } from 'zustand'
// We're borrowing one function from the zustand package.
// That's all we need. Zustand doesn't hide other stuff from us, there genuinely isn't more.

const useCounterStore = create((set) => ({
  // create takes a function as its only argument.
  // That function receives two tools named set and get.
  // We only need set for now. It's how we change state.

  count: 0,
  // This is a state field. It lives in the store.
  // Any component in our app can read it.

increment: () =>
    set((state) => ({ count: state.count + 1 })),
  // This is an action. Actions are just functions that call set.
  // We pass set a function that takes the OLD state and returns
  // an object describing what should change.
  // Zustand merges this object into the store for us.
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
  // Another action. Same pattern. Decrement by one.
  reset: () => set({ count: 0 }),
  // When we don't need the old state, we can just pass a plain object.
  // Zustand handles both forms.
}))
export default useCounterStore
// Ship the hook out so other files can import it.

Úsala:

// Counter.jsx
import useCounterStore from './useCounterStore'

function Counter() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)
  const reset = useCounterStore((state) => state.reset)
  // Each line is a subscription.
  // We grab exactly what we need and no more.
  // This matters for performance, we'll get to why.
  return (
    <div>
      <h1>Count {count}</h1>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>reset</button>
    </div>
  )
}
export default Counter

Monta <Counter /> en cualquier lugar. Los conteos cambian. Fíjate en lo que falta: envoltorios Provider, inicialización de contexto, constantes de acción, reductores, connect, mapDispatchToProps, configuración de thunk. Solo se necesita una llamada a create y un gancho.

“Espera, ¿eso es realmente todo?”

Dentro del mecanismo

create genera un objeto simple para almacenar el estado y una lista vacía de oyentes en el ámbito del módulo (un singleton tan pronto como se carga el módulo).

Cuando un componente utiliza el gancho:

  1. Zustand ejecuta el selector (por ejemplo (state) => state.count) con respecto al estado actual y devuelve ese valor.
  2. Registra al componente junto con el selector como un oyente. En actualizaciones posteriores vuelve a ejecutar cada selector y vuelve a renderizar solo cuando el valor seleccionado cambia.

No hay React Context en la ruta principal; se trata más bien de un pequeño emisor de eventos con elementos integrados de React. Dado que el almacén se encuentra fuera del árbol de React, cada importación del mismo módulo comparte una única instancia. Existen almacenes multiinstancia tradicionales para los casos excepcionales en los que se necesitan; la mayoría de las aplicaciones nunca requieren esta alternativa.

Todo el ciclo de vida: cuatro pasos y un bucle de retroalimentación

Selector (no lo pase por alto)

Los selectores deciden cuándo se vuelve a renderizar un componente.

Patrón A: toda la tienda (generalmente incorrecto):

const store = useCounterStore()
// No selector. Zustand returns the entire store object.
// Your component now re-renders on ANY state change, even unrelated ones.
// If someone else changes a different piece of state you don't care about,
// this component still wastes a render cycle.

Patrón B: un campo (generalmente correcto):

const count = useCounterStore((state) => state.count)
// Now your component only re-renders when count changes specifically.
// Other state can update freely. This component sleeps through it.

Patrón C: trampa con objetos literales:

const { count, increment } = useCounterStore((state) => ({
  count: state.count,
  increment: state.increment,
}))
// Looks clean. It's a trap.
// This selector returns a NEW object every time it runs.
// Zustand compares the new object to the old object. They're different objects.
// Result: this component re-renders on every state change in the entire store.
// Worse than pattern A for obvious reasons.

Cada vez que se llama a la función, se crea un objeto nuevo, lo que hace que parezca “nuevo” incluso cuando los campos no han cambiado, por lo que se producen renders repetidos constantemente.

Selector incorrecto a la izquierda. Selector correcto a la derecha. La diferencia es drástica en aplicaciones grandes.

¿Necesita varios campos sin crear nuevos objetos? Use useShallow:

import { useShallow } from 'zustand/react/shallow'
const { count, increment } = useCounterStore(
  useShallow((state) => ({
    count: state.count,
    increment: state.increment,
  }))
)
// useShallow tells Zustand "compare the returned object field by field."
// If count and increment didn't change individually, no re-render.
// Now you get clean destructuring AND performance.

Muchos conjuntos de código en producción prefieren, en su lugar, realizar múltiples llamadas a hooks específicos. Regla general: utilice la porción más pequeña posible; si no está seguro, divida los hooks en lugar de combinarlos.

Acciones: sincrónicas y asíncronas

Las acciones se encuentran en el mismo objeto que el estado. Las actualizaciones sincrónicas utilizan set:

const useCartStore = create((set, get) => ({
  items: [],

addItem: (product) =>
    set((state) => ({
      items: [...state.items, product],
    })),
  // Spread the existing items into a new array.
  // Add the new product at the end.
  // Return the updated items array to merge back into state.
  removeItem: (productId) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== productId),
    })),
  // Filter out the item with the matching id.
  // Return the filtered array.
  clear: () => set({ items: [] }),
  // Reset to empty. No need to read old state.
}))

get permite leer el estado actual dentro de una acción sin registrar un listener de React, lo cual es útil para tomar decisiones:

const useWalletStore = create((set, get) => ({
  balance: 100,

tryWithdraw: (amount) => {
    const currentBalance = get().balance
    // Read the current balance at this exact moment.
    // No subscription. No re-render. Just a fresh read.
    if (currentBalance < amount) {
      return { success: false, message: 'Not enough funds' }
      // Bail out without touching state.
    }
    set((state) => ({ balance: state.balance - amount }))
    return { success: true, message: 'Withdrawn successfully' }
  },
}))

El trabajo asíncrono se realiza con async/await habituales, sin necesidad de usar middleware thunk:

const usePostStore = create((set) => ({
  posts: [],
  loading: false,
  error: null,

fetchPosts: async () => {
    set({ loading: true, error: null })
    // Flip loading to true. Clear any previous errors.
    // Components showing a spinner will now show it.
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts')
      const data = await response.json()
      // Hit the API. Wait for it. Parse the JSON.
      set({ posts: data, loading: false })
      // Store the posts. Flip loading back to false.
      // Components showing the list now have data.
    } catch (err) {
      set({ error: err.message, loading: false })
      // If anything exploded, record the error message.
      // Components can now show an error banner.
    }
  },
}))

Esa ausencia de código genérico relacionado con las cadenas de procesamiento es la diferencia práctica respecto a las configuraciones asíncronas clásicas de Redux.

Middleware: capacidades acumuladas

El middleware envuelve al creador del store. A continuación se detallan los componentes más comunes.

Persist — sobrevivir a las recargas

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'light',
      toggle: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
    }),
    {
      name: 'theme-storage',
      // The key under which Zustand saves your state in localStorage.
      // Name it whatever you want, just make it unique.
    }
  )
)

El tema (o cualquier otro campo que elijas) se recupera después de la actualización. En las Herramientas de desarrollo → Aplicación → Almacenamiento local se muestra el bloque JSON correspondiente.

Herramientas de desarrollo — inspeccionar acciones

A pesar del nombre Redux, la extensión del navegador funciona cuando el store está envuelto con devtools:

import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set(
          (s) => ({ count: s.count + 1 }),
          false,
          'counter/increment'
        ),
      // The third argument is the action name shown in DevTools.
      // If you skip it, you'll see "anonymous" for every action, which is useless for debugging.
    }),
    { name: 'CounterStore' }
  )
)

Immer — actualizaciones anidadas sin uso de spreads anidados

Distribuciones anidadas e inmutables que causan problemas:

// Without immer, updating a deeply nested field:
updateCity: (city) => set((state) => ({
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      address: {
        ...state.user.profile.address,
        city,
      },
    },
  },
}))

Immer hace que las actualizaciones parezcan mutables mientras permanecen inmutables en su interior:

import { immer } from 'zustand/middleware/immer'
const useStore = create(
  immer((set) => ({
    user: { profile: { address: { city: '' } } },
    updateCity: (city) =>
      set((state) => {
        state.user.profile.address.city = city
        // Looks like mutation. Isn't actually mutation.
        // Immer tracks the change and produces a new immutable state object.
      }),
  }))
)

Escuchadores externos conscientes del selector

Para escuchadores que no son de React y que solo deben activarse cuando cambia un segmento seleccionado, Zustand incluye el middleware subscribeWithSelector:

import { subscribeWithSelector } from 'zustand/middleware'
const useCartStore = create(
  subscribeWithSelector((set) => ({
    items: [],
    addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  }))
)
// Somewhere outside React, like in an analytics file:
useCartStore.subscribe(
  (state) => state.items,
  // The selector. We only care about items.
  (items, previousItems) => {
    console.log('Cart changed', { from: previousItems, to: items })
    // This runs every time items changes, with both old and new values.
    // No React component involved. No re-render. Just a side effect.
  }
)

Apilamiento

const useStore = create(
  persist(
    devtools(
      subscribeWithSelector(
        immer((set, get) => ({
          // your store definition
        }))
      ),
      { name: 'MyStore' }
    ),
    { name: 'my-store-storage' }
  )
)

Feo una vez, y luego se olvida.

La estructura en capas de los middleware. Cada capa agrega una funcionalidad.

Slices escalables

Un solo archivo es suficiente hasta que el carrito de compras, la autenticación, el tema y las notificaciones entran en conflicto. Las fábricas de slices mantienen los dominios separados al componer una única tienda:

// cartSlice.js
export const createCartSlice = (set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  clearCart: () => set({ items: [] }),
})

// authSlice.js
export const createAuthSlice = (set, get) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
})
// useAppStore.js
import { create } from 'zustand'
import { createCartSlice } from './cartSlice'
import { createAuthSlice } from './authSlice'
const useAppStore = create((set, get) => ({
  ...createCartSlice(set, get),
  ...createAuthSlice(set, get),
}))

Los componentes siguen seleccionando lo que necesitan. Pasados unos cinco dominios, los slices generan complejidad; antes de eso, un solo archivo resulta más claro.

Pruebas sin montar la interfaz de usuario

Las tiendas son módulos simples. Restablecer, llamar a acciones, verificar:

// useCounterStore.test.js
import useCounterStore from './useCounterStore'

describe('counter store', () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0 })
    // Reset state before each test.
    // setState is exposed on the hook itself, not just for components.
  })
  it('increments count', () => {
    useCounterStore.getState().increment()
    // getState gives you the current store object outside of React.
    // .increment() calls the action.
    expect(useCounterStore.getState().count).toBe(1)
    // Verify the count went from 0 to 1.
  })
  it('resets count', () => {
    useCounterStore.getState().increment()
    useCounterStore.getState().increment()
    useCounterStore.getState().reset()
    expect(useCounterStore.getState().count).toBe(0)
  })
})

Sin renderizador, sin proveedor simulado: las pruebas unitarias siguen siendo rápidas y precisas.

Proyecto: Estación de hidratación para plantas

Crea un rastreador para el cuidado de plantas: agrega plantas con la frecuencia de riego, marca las que ya se han regado, resalta aquellas con retraso, muestra estadísticas y mantiene la información al recargar. Funcionalidades:

  1. Agregar planta (nombre + días entre riegos)
  2. Lista de plantas
  3. Riego con un solo clic
  4. Marcar plantas sedientas
  5. Eliminar plantas
  6. Panel de estadísticas
  7. Mantenimiento de datos al recargar

Toda la aplicación en una sola página. Cuatro componentes, un almacén de datos y plantas felices.

Archivo 1 — almacén de datos

src/store/usePlantStore.js:

// src/store/usePlantStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

// Helper function. Not exported. Just used internally.
// Given a plant object, returns true if it needs water.
const isThirsty = (plant) => {
  const msPerDay = 1000 * 60 * 60 * 24
  // milliseconds in a second times seconds in a minute
  // times minutes in an hour times hours in a day
  const daysSinceWatering = (Date.now() - plant.lastWatered) / msPerDay
  return daysSinceWatering >= plant.frequencyDays
}
const usePlantStore = create(
  persist(
    (set, get) => ({
      plants: [],
      // The big array that holds every plant.
      // Each plant will be an object with id, name, frequencyDays, lastWatered.
      addPlant: (name, frequencyDays) =>
        set((state) => ({
          plants: [
            ...state.plants,
            {
              id: Date.now() + Math.random(),
              // Quick unique id. Good enough for a personal app.
              // For production use nanoid or uuid from npm.
              name,
              frequencyDays,
              lastWatered: Date.now(),
              // New plants count as freshly watered.
              // Otherwise they'd show as thirsty the second they're added, which is mean.
            },
          ],
        })),
      waterPlant: (id) =>
        set((state) => ({
          plants: state.plants.map((plant) =>
            plant.id === id
              ? { ...plant, lastWatered: Date.now() }
              : plant
          ),
          // Find the matching plant, return a new object with updated timestamp.
          // Leave all other plants untouched.
        })),
      removePlant: (id) =>
        set((state) => ({
          plants: state.plants.filter((plant) => plant.id !== id),
          // Drop the matching plant. Keep everyone else.
        })),
      // These are getter-style helpers using get().
      // They're not stored, they're computed from current state.
      thirstyCount: () => get().plants.filter(isThirsty).length,
      happyCount: () => get().plants.filter((p) => !isThirsty(p)).length,
      isPlantThirsty: (id) => {
        const plant = get().plants.find((p) => p.id === id)
        return plant ? isThirsty(plant) : false
      },
    }),
    {
      name: 'plant-hydration-v1',
      // localStorage key. Prefixing with v1 lets me change schema later
      // without breaking existing users' data.
    }
  )
)
export default usePlantStore

Archivo 2 — formulario de agregación

// src/components/AddPlantForm.jsx
import { useState } from 'react'
import usePlantStore from '../store/usePlantStore'

function AddPlantForm() {
  const [name, setName] = useState('')
  const [frequency, setFrequency] = useState(3)
  // These are local to this component.
  // Form inputs are textbook useState territory.
  // They don't need to be global.
  const addPlant = usePlantStore((state) => state.addPlant)
  // Only grab the action we need.
  // We don't care about the plants array here, we don't pull it.
  const handleSubmit = (event) => {
    event.preventDefault()
    // Prevent the default form submission that reloads the page.
    // Modern React always wants this call on form events.
    const trimmed = name.trim()
    if (!trimmed) return
    // Reject empty or whitespace-only names silently.
    addPlant(trimmed, Number(frequency))
    // Call the store action.
    // Number() converts the string from the input into a number.
    setName('')
    setFrequency(3)
    // Clear the form so the user can add another plant easily.
  }
  return (
    <form onSubmit={handleSubmit} className="add-plant-form">
      <h2>Add a Plant</h2>
      <label className="field">
        <span>Plant name</span>
        <input
          type="text"
          value={name}
          onChange={(event) => setName(event.target.value)}
          placeholder="Monstera, Pothos, Something Latin"
        />
      </label>
      <label className="field">
        <span>Water every</span>
        <div className="freq-input">
          <input
            type="number"
            min="1"
            max="60"
            value={frequency}
            onChange={(event) => setFrequency(event.target.value)}
          />
          <span>days</span>
        </div>
      </label>
      <button type="submit">Add Plant</button>
    </form>
  )
}
export default AddPlantForm

Archivo 3 — lista de plantas

// src/components/PlantList.jsx
import usePlantStore from '../store/usePlantStore'

function PlantList() {
  const plants = usePlantStore((state) => state.plants)
  const waterPlant = usePlantStore((state) => state.waterPlant)
  const removePlant = usePlantStore((state) => state.removePlant)
  // Three separate subscriptions.
  // Clean. Performant. Obvious.
  if (plants.length === 0) {
    return (
      <div className="empty-state">
        <p>No plants yet. Add one to start tracking.</p>
      </div>
    )
    // Empty state so the UI doesn't look broken.
    // Always tell the user what they can do next.
  }
  const daysSinceWatered = (timestamp) => {
    const msPerDay = 1000 * 60 * 60 * 24
    return Math.floor((Date.now() - timestamp) / msPerDay)
  }
  return (
    <section className="plant-list">
      <h2>Your Plants</h2>
      <ul>
        {plants.map((plant) => {
          const days = daysSinceWatered(plant.lastWatered)
          const thirsty = days >= plant.frequencyDays
          // Compute thirsty status on the fly.
          // Cheap calculation. Premature optimization would be storing this.
          return (
            <li
              key={plant.id}
              className={thirsty ? 'plant thirsty' : 'plant happy'}
            >
              <div className="plant-meta">
                <strong className="plant-name">{plant.name}</strong>
                <span className="plant-when">
                  {days === 0
                    ? 'Watered today'
                    : `Last watered ${days} ${days === 1 ? 'day' : 'days'} ago`}
                </span>
                {thirsty && <span className="badge">THIRSTY</span>}
              </div>
              <div className="plant-actions">
                <button
                  onClick={() => waterPlant(plant.id)}
                  className="btn-primary"
                >
                  Water
                </button>
                <button
                  onClick={() => removePlant(plant.id)}
                  className="btn-danger"
                >
                  Remove
                </button>
              </div>
            </li>
          )
        })}
      </ul>
    </section>
  )
}
export default PlantList

Archivo 4 — estadísticas

// src/components/StatsPanel.jsx
import usePlantStore from '../store/usePlantStore'

function StatsPanel() {
  const plants = usePlantStore((state) => state.plants)
  // We subscribe to the plants array because our stats depend on it.
  // When plants change, this re-renders with fresh totals.
  const total = plants.length
  const thirstyCount = plants.filter((plant) => {
    const days = (Date.now() - plant.lastWatered) / (1000 * 60 * 60 * 24)
    return days >= plant.frequencyDays
  }).length
  const happyCount = total - thirstyCount
  return (
    <aside className="stats-panel">
      <h2>Quick Stats</h2>
      <div className="stat-row">
        <span>Total plants</span>
        <strong>{total}</strong>
      </div>
      <div className="stat-row">
        <span>Needs water</span>
        <strong className="danger">{thirstyCount}</strong>
      </div>
      <div className="stat-row">
        <span>Happy plants</span>
        <strong className="success">{happyCount}</strong>
      </div>
      {thirstyCount > 0 && (
        <p className="nudge">
          {thirstyCount === 1
            ? 'One plant is waiting on you.'
            : `${thirstyCount} plants are waiting on you.`}
        </p>
      )}
    </aside>
  )
}
export default StatsPanel

Archivo 5 — estructura principal de la app

// src/App.jsx
import AddPlantForm from './components/AddPlantForm'
import StatsPanel from './components/StatsPanel'
import PlantList from './components/PlantList'
import './App.css'

function App() {
  return (
    <div className="app">
      <header className="app-header">
        <h1>Plant Hydration Station</h1>
        <p className="tagline">Don't let them down</p>
      </header>
      <div className="grid">
        <AddPlantForm />
        <StatsPanel />
      </div>
      <PlantList />
    </div>
  )
  // Notice something beautiful here.
  // No Provider wrapping anything.
  // No props passed to any component.
  // Every component reaches into the store on its own.
}
export default App

Archivo 6 — CSS

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: system-ui, -apple-system, sans-serif;
  background: #F5F3FF;
  color: #2D3436;
}

.app { max-width: 960px; margin: 0 auto; padding: 24px; }
.app-header {
  background: linear-gradient(135deg, #6C5CE7, #A29BFE);
  color: white;
  padding: 24px 28px;
  border-radius: 16px;
  margin-bottom: 24px;
  box-shadow: 0 8px 24px rgba(108, 92, 231, 0.15);
}
.app-header h1 { margin: 0; font-size: 28px; }
.tagline { margin: 4px 0 0; opacity: 0.9; font-style: italic; }
.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  margin-bottom: 20px;
}
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
.add-plant-form, .stats-panel, .plant-list {
  background: white;
  padding: 20px 22px;
  border-radius: 14px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.field { display: block; margin-bottom: 14px; }
.field span { display: block; font-size: 13px; color: #636E72; margin-bottom: 6px; }
.field input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #DFE6E9;
  border-radius: 8px;
  font-size: 14px;
}
.freq-input { display: flex; align-items: center; gap: 10px; }
.freq-input input { width: 80px; }
button {
  border: none;
  padding: 10px 18px;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  font-size: 14px;
}
.add-plant-form button[type="submit"] {
  background: #6C5CE7;
  color: white;
  width: 100%;
}
.btn-primary { background: #0984E3; color: white; }
.btn-danger { background: #FFE5E0; color: #E17055; }
.stat-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #F1F2F6; }
.stat-row:last-of-type { border: none; }
.stat-row .danger { color: #E17055; }
.stat-row .success { color: #00B894; }
.nudge { background: #FFF5F0; color: #E17055; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; }
.plant-list ul { list-style: none; padding: 0; margin: 0; }
.plant { display: flex; justify-content: space-between; align-items: center; padding: 14px 6px; border-bottom: 1px solid #F1F2F6; }
.plant:last-child { border: none; }
.plant.thirsty .plant-name::before { content: "🚨 "; }
.plant-meta { display: flex; flex-direction: column; gap: 3px; }
.plant-name { font-size: 15px; }
.plant-when { font-size: 12px; color: #636E72; }
.badge { background: #FFE5E0; color: #E17055; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: bold; display: inline-block; margin-top: 2px; }
.plant-actions { display: flex; gap: 8px; }
.empty-state { text-align: center; padding: 40px 20px; color: #636E72; }

Ejecuta npm run dev, agrega plantas, actualiza la página y verifica que permanezcan; riega una de ellas y observa cómo se reinician las marcas de tiempo. El almacenamiento local conserva los mismos datos en una segunda pestaña.

Tu aplicación ya está lista. Puedes darle el estilo que desees a partir de aquí.

Verificar con herramientas del navegador

Aplicación/Almacenamiento → Almacenamiento local → la clave plant-hydration-v1 contiene el JSON persistido. El middleware Persist escribe los cambios y vuelve a cargar los datos antes de que se muestren. Con el middleware devtools junto con la extensión Redux DevTools, cada acción se muestra con funcionalidad de viaje en el tiempo para almacenamientos más grandes.

Errores comunes

  1. Gancho para todo el almacenamiento — usar useStore() sin selector hace que se vuelva a renderizar en cada cambio. Siempre debes seleccionar un elemento específico.
  2. Objetos nuevos provenientes de selectores — soluciona esto con useShallow o dividiendo los hooks.
  • Almacenamiento de valores derivados — calcule thirstyCount a partir de plants; no mantenga un segundo contador obsoleto.
  • Falta el campo persistente name — es obligatorio; su omisión provoca un error al iniciar.
  • Un único almacén para siempre — divídalo cuando aumente el número de dominios.
  • Ceremonia Redux en Zustand — omita los enums de tipo de acción y los reducers switch enormes a menos que haya una necesidad real.
  • Olvidar los singulares — una función create por módulo se comparte; existen APIs estándar para necesidades por instancia.
  • Omitir pruebas del almacén — use getState, act y assert; detecte las regresiones temprano.
  • Cuando Zustand no es la herramienta adecuada

    • useState — interfaz de usuario verdaderamente local (modales, efectos al pasar el cursor, borradores de campos).
  • TanStack Query / SWR — caché en el servidor, recarga, eliminación de duplicados, actualizaciones remotas optimistas. Combina Query para el estado del servidor con Zustand para el estado del cliente.
  • Context — actualizaciones esporádicas (tokens de tema) en todo un subárbol.
  • Keep Redux — bases de código existentes en Redux, una gran inversión en herramientas para trabajar con historial de cambios, o flujos verdaderamente complejos donde la estructura ya genera beneficios por sí sola. Migrar solo cuando los ahorros superen el costo de la migración.
  • Boceto en TypeScript

    import { create } from 'zustand'
    
    type Plant = {
      id: number
      name: string
      frequencyDays: number
      lastWatered: number
    }
    type PlantState = {
      plants: Plant[]
      addPlant: (name: string, frequencyDays: number) => void
      waterPlant: (id: number) => void
      removePlant: (id: number) => void
      thirstyCount: () => number
    }
    const usePlantStore = create<PlantState>((set, get) => ({
      plants: [],
      addPlant: (name, frequencyDays) =>
        set((state) => ({
          plants: [
            ...state.plants,
            { id: Date.now(), name, frequencyDays, lastWatered: Date.now() },
          ],
        })),
      waterPlant: (id) =>
        set((state) => ({
          plants: state.plants.map((p) =>
            p.id === id ? { ...p, lastWatered: Date.now() } : p
          ),
        })),
      removePlant: (id) =>
        set((state) => ({
          plants: state.plants.filter((p) => p.id !== id),
        })),
      thirstyCount: () =>
        get().plants.filter((p) => {
          const days = (Date.now() - p.lastWatered) / (1000 * 60 * 60 * 24)
          return days >= p.frequencyDays
        }).length,
    }))
    

    Un tipo con forma de almacén más create<...>; la inferencia se encarga del resto.

    Perspectiva más amplia

    Zustand no eliminó la base instalada de Redux —las reescrituras son costosas—, pero las nuevas aplicaciones React optan cada vez más por almacenes de cliente más ligeros. Las encuestas de satisfacción en las recientes ediciones del State of React sitúan a Zustand cerca de la cima en cuanto a preferencia para ser utilizado nuevamente. Una combinación común para 2026: Zustand para el estado del cliente, TanStack Query para el estado del servidor, atómicos Jotai ocasionalmente, Context para temas/configuraciones, Redux Toolkit solo cuando ya está presente, y useState tradicional para la interfaz de usuario local.

    Esa combinación tiende a facilitar el inicio del desarrollo más rápido que los frameworks centrados en Redux y genera menos problemas diarios para los desarrolladores.

    Zustand Bear

    Los equipos de desarrollo siguen documentando las convenciones del almacén (nomenclatura, límites de los segmentos, claves de persistencia) porque la libertad sin normas genera caos. La ventaja es que esas convenciones son sencillas: los selectores son específicos, las acciones se encuentran junto al estado, el middleware se utiliza de forma intencionada y la caché del servidor permanece fuera del almacén del cliente. Al mantener esos límites claros, Zustand sigue siendo la solución de diez líneas frente al kit inicial de Redux de cien líneas, sin pretender que cada aplicación sea siempre una demostración básica.

    Más allá de la muestra de plantilla, los mismos patrones se aplican a carritos, banderas de funcionalidad, borradores de asistentes y elementos decorativos de la interfaz. Comience con un único archivo del store, añada mecanismos de persistencia cuando a los usuarios no les guste perder su trabajo, incorpore DevTools cuando los errores se vuelvan sutiles, introduzca slices cuando la barra de desplazamiento del archivo se convierta en un problema, y mantenga Query para todo lo que requiera comunicación bidireccional con una API. Esa progresión coincide con la forma en que realmente crecen la mayoría de los proyectos exitosos basados en Zustand: de manera pequeña, explícita y evitando ceremonias que no aporten seguridad.

    Profundizando en selectores y rendimiento

    La disciplina con los selectores marca la diferencia entre un panel de control ágil y uno misteriosamente lento. Cada re-renderizado innecesario vuelve a ejecutar el JSX, los efectos que dependen de las propiedades y el proceso de reconciliación de hijos. El modelo de listeners de Zustand solo es eficiente cuando los selectores devuelven primitivos estables o estructuras comparadas con cuidado.

    Es preferible seleccionar valores booleanos, números y cadenas de texto. Cuando se elige una función de acción, suele mantenerse estable a través de las actualizaciones ya que se encuentra en el objeto store, por lo que combinar count y increment en dos hooks está bien. Evite seleccionar arrays completos si el componente solo necesita items.length; en su lugar, seleccione la longitud (o un valor booleano derivado) para que los cambios en otros lugares no activen widgets inactivos.

    La igualdad es importante. La comparación por defecto es Object.is. Por eso falla el retorno de { a, b } desde un selector: un objeto nuevo no cumple con la condición de Object.is incluso cuando a y b permanecen sin cambios. useShallow compara un solo nivel de campos. Para estructuras profundas, normalice el estado para que la interfaz muestre campos planos, o calcule intencionalmente una huella primitiva.

    Las listas merecen un cuidado especial. Es aceptable mapear plants en tres componentes siempre y cuando cada uno solo necesite la referencia del array cuando cambie la pertenencia o la identidad de un elemento. Si un panel solo muestra nombres, considere usar un selector que devuelva una cadena ordenada de IDs correspondientes; así permanecerá estable incluso cuando cambien campos no relacionados con las plantas.

    Peligros de la persistencia y versionado

    El middleware de persistencia parece mágico hasta que se produce un cambio en el esquema. Siempre establezca un name estable para la clave de almacenamiento. Cuando cambie la estructura del estado persistido, aumente la versión y proporcione una función migrate para que los archivos JSON antiguos no provoquen errores al cargar la aplicación. La persistencia parcial (partialize) evita que los datos confidenciales y las flags temporales de la interfaz aparezcan en Local Storage; los tokens y los bits relacionados con modales únicos rara vez deben guardarse en disco.

    Tenga en cuenta el momento de la hidratación: la primera renderización del cliente podría mostrar temporalmente los valores predeterminados antes de que finalice el proceso de rehidratación. En el caso de SSR o frameworks que dibujan en el servidor, proteja la interfaz de usuario que dependa de valores persistidos detrás de una marca de hidratación, o acepte un breve aparecimiento del tema predeterminado. Documente el enfoque elegido para que los compañeros de equipo no intenten “arreglar” errores temporales que en realidad son consecuencia de la competencia por la hidratación.

    El comportamiento entre pestañas también sorprende a las personas. Las escrituras en Local Storage desde una pestaña son visibles en las demás a través del evento storage, pero la ruta de persistencia predeterminada de Zustand no fusiona automáticamente las ediciones concurrentes. Para pestañas colaborativas, o bien acepte que gane la última edición realizada, o añada una capa de sincronización explícita mediante BroadcastChannel sobre el almacén de datos.

    Diseñando acciones que sigan siendo sencillas

    Las acciones de buen estado son pequeñas, llevan nombres que reflejan la intención del usuario y no contienen JSX. addPlant, waterPlant y removePlant son mejores que los datos generados por setPlants en la interfaz de usuario. Mantenga la validación cerca de la acción: rechace nombres vacíos, limite los intervalos de riego e ignore los IDs desconocidos. Devolver resultados tempranamente desde una acción es más claro que permitir que datos defectuosos permanezcan en el almacén durante un ciclo de renderizado.

    Las acciones asíncronas deben establecer los campos loading y error de forma explícita cuando la interfaz debe reflejar el progreso. El registro tipo “dispara y olvida” puede omitir esas marcas. Cuando varias llamadas asíncronas se ejecutan simultáneamente, capture un ID de solicitud o utilice un controlador de aborto para que una respuesta antigua no sobrescriba a una más reciente. Nada de esto requiere middleware; basta con una secuenciación cuidadosa de las llamadas set.

    Los ayudantes derivados pueden existir como funciones simples fuera del almacén (lo más fácil para probar) o como getters calculados dentro de los selectores. Prefiera funciones puras importadas tanto por el almacén como por la interfaz de usuario para que Jest pueda verificar los cálculos relacionados con el riego sin necesidad de React.

    Notas para la guía de la aplicación de plantas

    Al pegar los seis archivos, mantenga las rutas de importación consistentes con la plantilla de Vite (../store/... desde components). Si la lista parece vacía después de actualizarla, revise la clave de persistencia en las Herramientas de Desarrollo y confirme que name: 'plant-hydration-v1' coincida con lo esperado. El riego debe actualizar lastWateredAt (o un campo equivalente) para que el selector relacionado cambie sin necesidad de recargar toda la página.

    El estilo en App.css está diseñado de forma intencionalmente sencilla. Puedes cambiar fuentes y colores libremente; el objetivo de aprendizaje es el flujo de datos, no la apariencia visual. Agregar un cuarto componente —por ejemplo, un botón que diga “regar a todos los sedientos”— es un ejercicio útil: selecciona los IDs de los elementos sedientos y luego llama a una acción que realice el riego en todos esos IDs con un solo set. Ese ejercicio refuerza el uso de actualizaciones por lotes en lugar de iterar con set desde el componente.

    Convenios del equipo que vale la pena anotar

    Acordar la estructura de los archivos (stores/ frente a carpetas de características colocadas juntas), la nomenclatura (useXStore) y si las acciones pueden llamar directamente a las APIs o deben pasar por una mutación Query que luego actualice Zustand. Muchos equipos prohíben completamente colocar listas de servidores en Zustand, manteniendo allí solo indicadores temporales del cliente. Escriban esa regla en el README una vez; así se evita que la mitad del código base reinvente un segundo caché.

    También acorden la nomenclatura de los DevTools: el middleware devtools acepta un nombre de almacén para que el panel de extensiones siga siendo legible cuando hay cinco almacenes abiertos. Las claves de persistencia deben tener un espacio de nombres por aplicación (myapp-theme-v1) para evitar colisiones en dominios compartidos.

    Comparar el impacto emocional, no solo las líneas de código

    Redux Toolkit redujo drásticamente la complejidad de Redux clásico, por lo que la comparación “100 líneas frente a 10” es en parte retórica. El contraste más significativo radica en la superficie conceptual: slices frente a una sola llamada a create, reducers frente a acciones in-place, pipelines de middleware frente a envoltorios opcionales, y árboles Provider frente a singulares de módulo. Los ingenieros que ya piensan en términos de reducers pueden ser productivos en cualquiera de estos entornos. Aquellos que desean compartir el estado del cliente sin adherirse estrictamente a un modelo de máquina de estados suelen completar las funcionalidades más rápido con Zustand.

    Nada de esto justifica omitir las revisiones. Una tienda con solo diez líneas aún puede contener un error de seguridad si almacena información personal sin consentimiento, o un problema de rendimiento si cada componente accede a todos los datos. Trate el almacén como una API de módulo pública: nombres de acciones estables, claves de persistencia documentadas y pruebas para los casos difíciles (listas vacías, IDs inválidos, solicitudes fallidas).

    Cerrando el ciclo de aprendizaje

    Una vez que la aplicación de gestión de plantas funcione, rompála intencionadamente: elimina un selector, devuelve un objeto literal, guarda los datos sin nombre, almacena un recuento derivado. Observa las formas en que fallará una vez para poder reconocerlas durante las revisiones de código. Luego restaura los patrones disciplinados. Ese breve experimento contribuye más al aprendizaje a largo plazo que otra demostración de contador pulida.

    La apuesta de Zustand es que la mayor parte del estado del cliente es aburrido: flags, borradores, carritos de compra, etc., y un estado aburrido merece una API aburrida. Mantén la información real del servidor en una biblioteca de caché diseñada específicamente para ello, conserva la interfaz local con useState, y reserva ese enfoque para los datos del cliente transversales que causaron tantos problemas con el uso de prop drilling. Si lo haces de manera consistente, la afirmación de “diez líneas” deja de sonar como publicidad y comienza a reflejar realmente la estructura del código.

    Lecturas relacionadas

  • Context, Zustand o Redux Toolkit: Elegir según la complejidad de las transiciones — Deje de elegir bibliotecas de estado según el tamaño de la aplicación. Context distribuye las dependencias en forma de árbol, Zustand optimiza las suscripciones y Redux Toolkit modela las transiciones de manera explícita.