◈ Gestión de Estado en React · CultivaFlow

Arquitectura de Estado Moderna

Guía de decisión e implementación para migrar de Redux legacy a una arquitectura híbrida Zustand + React Query.

◆ Cliente: CultivaFlow SaaS  ·  React 18 + TypeScript 5 + Vite
1 Categorías de Estado en CultivaFlow
Estado Local
UI de un solo componente: dropdown abierto, tab activa, estado de hover.
useState useReducer
Estado Global (UI)
Sidebar abierto/cerrado, modal activo, tema, usuario autenticado.
Zustand Jotai
Estado Servidor
Campañas, clientes, contenidos, créditos IA — datos remotos con caché.
React Query RTK Query
Estado URL
Filtros de campañas, paginación, búsqueda — persistente en URL.
React Router nuqs

◆ Recomendación para CultivaFlow

Estado UI global
Zustand Sidebar, modals, tema, usuario autenticado. App mediana — no necesita Redux.
Datos del servidor
React Query v5 Campañas, clientes, contenidos, créditos. Elimina la mayoría del Redux actual.
Estado local
useState / useReducer Formularios simples, tabs, accordions. Sin librería extra.
Filtros / paginación
React Router + nuqs Filtros de campañas en la URL para que sean enlazables y refrescables.
2 Arquitectura de Store Recomendada
Estado UI
■ useUIStore — sidebar, modal, theme
■ useAuthStore — user, session
Datos Servidor
▲ useCampaigns
▲ useClients
▲ useContents
▲ useCredits
Estado Local
● Forms
● Tabs
● Dropdowns
URL State
◇ Filtros de campaña en searchParams
◇ Paginación
store/useUIStore.ts Zustand
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

type ModalType = 'create-campaign' | 'delete-client' | 'upgrade-plan' | null

interface UIState {
  sidebarOpen: boolean
  activeModal: ModalType
  theme: 'light' | 'dark'
  // Actions
  toggleSidebar: () => void
  openModal: (modal: ModalType) => void
  closeModal: () => void
  toggleTheme: () => void
}

export const useUIStore = create<UIState>()(
  devtools(
    persist(
      (set) => ({
        sidebarOpen: true,
        activeModal: null,
        theme: 'dark',
        toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
        openModal: (modal) => set({ activeModal: modal }),
        closeModal: () => set({ activeModal: null }),
        toggleTheme: () => set((s) => ({
          theme: s.theme === 'dark' ? 'light' : 'dark'
        })),
      }),
      { name: 'cultivaflow-ui' }
    )
  )
)
hooks/useCampaigns.ts React Query v5
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Campaign, CampaignFilters } from '../types'

// Query keys factory — consistencia garantizada
export const campaignKeys = {
  all: ['campaigns'] as const,
  lists: () => [...campaignKeys.all, 'list'] as const,
  list: (filters: CampaignFilters) => [...campaignKeys.lists(), filters] as const,
  detail: (id: string) => [...campaignKeys.all, 'detail', id] as const,
}

export function useCampaigns(filters: CampaignFilters) {
  return useQuery({
    queryKey: campaignKeys.list(filters),
    queryFn: () => fetchCampaigns(filters),
    staleTime: 5 * 60 * 1000,  // 5 min — datos estables
    gcTime: 30 * 60 * 1000,   // 30 min cache
  })
}

// Mutación con actualización optimista
export function useUpdateCampaignStatus() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, status }: { id: string; status: Campaign['status'] }) =>
      updateCampaignStatus(id, status),
    onMutate: async ({ id, status }) => {
      await queryClient.cancelQueries({ queryKey: campaignKeys.detail(id) })
      const prev = queryClient.getQueryData(campaignKeys.detail(id))
      queryClient.setQueryData(campaignKeys.detail(id), (old: Campaign) =>
        ({ ...old, status })
      )
      return { prev }
    },
    onError: (_, { id }, context) => {
      queryClient.setQueryData(campaignKeys.detail(id), context?.prev)
    },
    onSettled: (_, __, { id }) => {
      queryClient.invalidateQueries({ queryKey: campaignKeys.detail(id) })
    },
  })
}
components/Dashboard.tsx UI combinado
// Zustand para UI state + React Query para datos — sin mezclar
export function Dashboard() {
  // ← Estado UI desde Zustand
  const { sidebarOpen, openModal } = useUIStore()

  // ← Datos desde React Query (filtros en URL)
  const [searchParams] = useSearchParams()
  const filters = { status: searchParams.get('status'), page: searchParams.get('page') }
  const { data: campaigns, isLoading, isError } = useCampaigns(filters)
  const updateStatus = useUpdateCampaignStatus()

  if (isLoading) return <DashboardSkeleton />
  if (isError) return <ErrorBoundary />

  return (
    <div className={sidebarOpen ? 'layout--sidebar' : 'layout--full'}>
      <CampaignTable
        campaigns={campaigns}
        onStatusChange={(id, status) => updateStatus.mutate({ id, status })}
        onNewCampaign={() => openModal('create-campaign')}
      />
    </div>
  )
}
3 Migración — Redux Legacy → React Query
✗ Antes — Redux legacy (boilerplate)
// actions/campaigns.js (legacy)
const FETCH_CAMPAIGNS = 'FETCH_CAMPAIGNS'
const FETCH_SUCCESS  = 'FETCH_CAMPAIGNS_SUCCESS'
const FETCH_ERROR    = 'FETCH_CAMPAIGNS_ERROR'

export const fetchCampaigns = (filters) =>
  async (dispatch) => {
    dispatch({ type: FETCH_CAMPAIGNS })
    try {
      const data = await api.get('/campaigns', filters)
      dispatch({ type: FETCH_SUCCESS, payload: data })
    } catch (err) {
      dispatch({ type: FETCH_ERROR, payload: err.message })
    }
  }

// reducers/campaigns.js — 50+ líneas más
function campaignsReducer(state = initialState, action) {
  switch (action.type) {
    case FETCH_CAMPAIGNS:
      return { ...state, loading: true }
    case FETCH_SUCCESS:
      return { ...state, loading: false, data: action.payload }
    // ...más casos
  }
}
✓ Después — React Query (limpio)
// hooks/useCampaigns.ts (nuevo)
import { useQuery } from '@tanstack/react-query'

export function useCampaigns(filters: CampaignFilters) {
  return useQuery({
    queryKey: ['campaigns', filters],
    queryFn: () => api.get('/campaigns', filters),
    staleTime: 5 * 60 * 1000,
  })
}

// En el componente — todo en una línea
const { data, isLoading, isError } = useCampaigns(filters)

// ✓ Sin acciones, sin reducers, sin thunks
// ✓ Caché automático + revalidación
// ✓ Loading/error states incluidos
// ✓ Devtools de React Query incluidas
// ✓ TypeScript inference perfecta
// → ~80% menos código que Redux

◆ Plan de migración gradual (sin romper producción)

Semana 1
Instalar React Query + Zustand. Crear QueryClient provider. Zustand store vacío.
Semana 2
Migrar estado servidor: campaignsSlice → React Query hook. Eliminar ese slice de Redux.
Semana 3
Migrar estado UI: sidebar/modal/theme a Zustand. Eliminar más slices Redux.
Semana 4
Eliminar Redux completamente. Quitar react-redux, @reduxjs/toolkit del package.json.
4 Buenas Prácticas en CultivaFlow
◆ Hacer
Colocar estado cerca de donde se usa — un modal solo para su componente
Usar React Query para todo dato que venga de la API (campañas, clientes, créditos)
Selectors en Zustand para evitar re-renders: useUIStore(s => s.sidebarOpen)
Tipar todo con TypeScript — interfaces para estado y retornos de hooks
Filtros de búsqueda y paginación siempre en la URL (enlazables)
Invalidar queries al mutar: queryClient.invalidateQueries
○ No hacer
No guardar datos de servidor en Zustand/Redux — React Query ya los gestiona
No poner todo en estado global — un acordeón abierto no necesita Zustand
No mezclar estado servidor y UI en el mismo store (error actual en CultivaFlow)
No mutar estado directamente — siempre usar los setters del store
No guardar datos derivados: si son calculables desde otros, compútalos
No duplicar la misma query en múltiples componentes sin query keys