workspace/projects/gamilit/docs/95-guias-desarrollo/frontend/STATE-MANAGEMENT.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- Configure workspace Git repository with comprehensive .gitignore
- Add Odoo as submodule for ERP reference code
- Include documentation: SETUP.md, GIT-STRUCTURE.md
- Add gitignore templates for projects (backend, frontend, database)
- Structure supports independent repos per project/subproject level

Workspace includes:
- core/ - Reusable patterns, modules, orchestration system
- projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.)
- knowledge-base/ - Reference code and patterns (includes Odoo submodule)
- devtools/ - Development tools and templates
- customers/ - Client implementations template

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:44:23 -06:00

10 KiB

Gestión de Estado Frontend

Versión: 1.0.0 Última Actualización: 2025-11-28 Aplica a: apps/frontend/src/


Resumen

GAMILIT utiliza una estrategia de gestión de estado híbrida:

  • React Query (TanStack Query): Estado del servidor (datos de API)
  • Zustand: Estado del cliente (UI, preferencias)
  • Context API: Estado muy localizado (themes, modals)

Cuándo Usar Cada Herramienta

Tipo de Estado Herramienta Ejemplos
Datos del servidor React Query Usuarios, logros, ejercicios
Cache de API React Query Respuestas cacheadas
UI global Zustand Sidebar abierto, tema
Autenticación Zustand Token, usuario actual
Formularios React Hook Form Inputs, validación
UI local useState Modals, toggles locales

React Query

Configuración

// shared/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,      // Datos frescos por 5 min
      gcTime: 10 * 60 * 1000,        // Garbage collection a 10 min
      retry: 1,                       // Reintentar 1 vez
      refetchOnWindowFocus: false,   // No refetch al enfocar ventana
    },
  },
});

Query Hook

// features/gamification/hooks/useUserStats.ts
import { useQuery } from '@tanstack/react-query';
import { gamificationService } from '../services/gamification.service';

export const useUserStats = () => {
  return useQuery({
    queryKey: ['user-stats'],
    queryFn: () => gamificationService.getMyStats(),
  });
};

// Uso en componente
const StatsDisplay = () => {
  const { data: stats, isLoading, error } = useUserStats();

  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <div>
      <span>XP: {stats.totalXp}</span>
      <span>Coins: {stats.mlCoins}</span>
    </div>
  );
};

Query con Parámetros

// features/exercises/hooks/useExercise.ts
export const useExercise = (exerciseId: string) => {
  return useQuery({
    queryKey: ['exercise', exerciseId],
    queryFn: () => exercisesService.getById(exerciseId),
    enabled: !!exerciseId, // Solo ejecutar si hay ID
  });
};

// features/gamification/hooks/useLeaderboard.ts
export const useLeaderboard = (filters: LeaderboardFilters) => {
  return useQuery({
    queryKey: ['leaderboard', filters],
    queryFn: () => gamificationService.getLeaderboard(filters),
  });
};

Mutation Hook

// features/exercises/hooks/useSubmitAnswer.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

export const useSubmitAnswer = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: { exerciseId: string; answer: any }) =>
      exercisesService.submitAnswer(data.exerciseId, data.answer),

    onSuccess: (result) => {
      // Invalidar queries relacionadas
      queryClient.invalidateQueries({ queryKey: ['user-stats'] });
      queryClient.invalidateQueries({ queryKey: ['achievements'] });
      queryClient.invalidateQueries({ queryKey: ['progress'] });
    },

    onError: (error) => {
      toast.error('Error al enviar respuesta');
    },
  });
};

// Uso
const ExerciseForm = ({ exerciseId }) => {
  const { mutate: submit, isPending } = useSubmitAnswer();

  const handleSubmit = (answer) => {
    submit({ exerciseId, answer });
  };

  return (
    <Button onClick={() => handleSubmit(answer)} disabled={isPending}>
      {isPending ? 'Enviando...' : 'Enviar'}
    </Button>
  );
};

Prefetching

// Prefetch al hover sobre un link
const ExerciseListItem = ({ exercise }) => {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: ['exercise', exercise.id],
      queryFn: () => exercisesService.getById(exercise.id),
    });
  };

  return (
    <Link to={`/exercises/${exercise.id}`} onMouseEnter={handleMouseEnter}>
      {exercise.title}
    </Link>
  );
};

Zustand

Crear Store

// features/auth/stores/auth.store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthState {
  token: string | null;
  user: User | null;
  isAuthenticated: boolean;

  // Actions
  setAuth: (token: string, user: User) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      isAuthenticated: false,

      setAuth: (token, user) =>
        set({
          token,
          user,
          isAuthenticated: true,
        }),

      logout: () =>
        set({
          token: null,
          user: null,
          isAuthenticated: false,
        }),
    }),
    {
      name: 'auth-storage', // Key en localStorage
      partialize: (state) => ({ token: state.token }), // Solo persistir token
    }
  )
);

Usar Store

// En componente
const Header = () => {
  const { user, logout } = useAuthStore();

  return (
    <div>
      <span>Hola, {user?.name}</span>
      <button onClick={logout}>Cerrar sesión</button>
    </div>
  );
};

// Acceso fuera de componente (en services)
const token = useAuthStore.getState().token;

Store de UI

// shared/stores/ui.store.ts
interface UIState {
  sidebarOpen: boolean;
  theme: 'light' | 'dark';

  toggleSidebar: () => void;
  setTheme: (theme: 'light' | 'dark') => void;
}

export const useUIStore = create<UIState>()(
  persist(
    (set) => ({
      sidebarOpen: true,
      theme: 'light',

      toggleSidebar: () =>
        set((state) => ({ sidebarOpen: !state.sidebarOpen })),

      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'ui-storage',
    }
  )
);

Store con Computed Values

// features/gamification/stores/gamification.store.ts
interface GamificationState {
  activeBoosts: Boost[];

  // Actions
  addBoost: (boost: Boost) => void;
  removeBoost: (boostId: string) => void;

  // Computed (usando selectors)
}

export const useGamificationStore = create<GamificationState>((set) => ({
  activeBoosts: [],

  addBoost: (boost) =>
    set((state) => ({
      activeBoosts: [...state.activeBoosts, boost],
    })),

  removeBoost: (boostId) =>
    set((state) => ({
      activeBoosts: state.activeBoosts.filter((b) => b.id !== boostId),
    })),
}));

// Selector para computed value
export const selectActiveXpMultiplier = (state: GamificationState) =>
  state.activeBoosts
    .filter((b) => b.type === 'xp_multiplier')
    .reduce((acc, b) => acc * b.value, 1);

// Uso
const xpMultiplier = useGamificationStore(selectActiveXpMultiplier);

Patrones Combinados

React Query + Zustand

// Sincronizar usuario de API con store local
const useInitAuth = () => {
  const setAuth = useAuthStore((s) => s.setAuth);
  const token = useAuthStore((s) => s.token);

  const { data: user } = useQuery({
    queryKey: ['current-user'],
    queryFn: authService.getCurrentUser,
    enabled: !!token,
  });

  useEffect(() => {
    if (user && token) {
      setAuth(token, user);
    }
  }, [user, token, setAuth]);
};

Optimistic Updates

const usePurchaseComodin = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: gamificationService.purchaseComodin,

    // Optimistic update
    onMutate: async (comodinId) => {
      await queryClient.cancelQueries({ queryKey: ['user-stats'] });

      const previousStats = queryClient.getQueryData(['user-stats']);

      // Actualizar optimistamente
      queryClient.setQueryData(['user-stats'], (old: UserStats) => ({
        ...old,
        mlCoins: old.mlCoins - getComodinPrice(comodinId),
      }));

      return { previousStats };
    },

    // Rollback en error
    onError: (err, comodinId, context) => {
      queryClient.setQueryData(['user-stats'], context.previousStats);
    },

    // Refetch para confirmar
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['user-stats'] });
    },
  });
};

Anti-Patrones a Evitar

1. Duplicar Estado del Servidor

// ❌ MAL: Copiar datos de API a Zustand
const store = create((set) => ({
  users: [],
  fetchUsers: async () => {
    const users = await api.getUsers();
    set({ users });
  },
}));

// ✅ BIEN: Usar React Query
const useUsers = () => useQuery({
  queryKey: ['users'],
  queryFn: api.getUsers,
});

2. Props Drilling Excesivo

// ❌ MAL: Pasar props por 5 niveles
<App user={user}>
  <Layout user={user}>
    <Page user={user}>
      <Section user={user}>
        <Component user={user} />

// ✅ BIEN: Usar store o context
const Component = () => {
  const user = useAuthStore((s) => s.user);
};

3. Efectos Innecesarios

// ❌ MAL: useEffect para derivar estado
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// ✅ BIEN: Calcular directamente
const fullName = `${firstName} ${lastName}`;

DevTools

React Query DevTools

// main.tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

Zustand DevTools

import { devtools } from 'zustand/middleware';

const useStore = create(
  devtools(
    (set) => ({
      // ...state
    }),
    { name: 'MyStore' }
  )
);

Buenas Prácticas

  1. Servidor = React Query: Todo dato de API
  2. Cliente = Zustand: Solo estado de UI/preferencias
  3. Selectores estrechos: Solo seleccionar lo necesario
  4. Invalidar vs Refetch: Preferir invalidateQueries
  5. Keys descriptivas: ['users', userId, 'posts']
  6. Persist selectivo: Solo persistir lo necesario

Ver También