Paso 1 de 4
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.
Paso 2 de 4
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> ) }
Paso 3 de 4
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.
Paso 4 de 4
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