- 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>
466 lines
10 KiB
Markdown
466 lines
10 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
|
|
```typescript
|
|
// main.tsx
|
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
<App />
|
|
<ReactQueryDevtools initialIsOpen={false} />
|
|
</QueryClientProvider>
|
|
```
|
|
|
|
### Zustand DevTools
|
|
|
|
```typescript
|
|
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
|
|
|
|
- [API-INTEGRATION.md](./API-INTEGRATION.md) - Integración con API
|
|
- [ESTRUCTURA-FEATURES.md](./ESTRUCTURA-FEATURES.md) - Dónde ubicar stores
|