- 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>
678 lines
15 KiB
Markdown
678 lines
15 KiB
Markdown
# Patrones de Custom Hooks - Frontend GAMILIT
|
|
|
|
**Fecha de creacion:** 2025-11-29
|
|
**Version:** 1.0
|
|
**Estado:** VIGENTE
|
|
**Contexto:** Estandarizacion de 30+ custom hooks
|
|
|
|
---
|
|
|
|
## 1. Principios Fundamentales
|
|
|
|
### 1.1 Proposito de Custom Hooks
|
|
- **Encapsular logica reutilizable**: Extraer logica de componentes
|
|
- **Compartir estado**: Entre componentes sin prop drilling
|
|
- **Abstraer complejidad**: Ocultar detalles de implementacion
|
|
- **Testear aisladamente**: Logica separada de UI
|
|
|
|
### 1.2 Regla de Extraccion
|
|
> Si una logica con hooks se repite en 2+ componentes, crea un custom hook.
|
|
|
|
### 1.3 Naming Convention
|
|
- Siempre empezar con `use`: `useAuth`, `useForm`, `useDebounce`
|
|
- Nombre descriptivo de lo que hace: `useUserData`, `useLocalStorage`
|
|
- Verbos para acciones: `useFetchUser`, `useSubmitForm`
|
|
|
|
---
|
|
|
|
## 2. Anatomia de un Custom Hook
|
|
|
|
### 2.1 Estructura Estandar
|
|
```typescript
|
|
/**
|
|
* useHookName
|
|
* @description Breve descripcion del hook
|
|
* @param {ParamType} param - Descripcion del parametro
|
|
* @returns {ReturnType} Descripcion del retorno
|
|
*
|
|
* @example
|
|
* const { data, isLoading } = useHookName(param);
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
// 1. Tipos
|
|
interface UseHookNameParams {
|
|
initialValue?: string;
|
|
onSuccess?: (data: Data) => void;
|
|
}
|
|
|
|
interface UseHookNameReturn {
|
|
data: Data | null;
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
refetch: () => void;
|
|
}
|
|
|
|
// 2. Hook principal
|
|
export function useHookName({
|
|
initialValue = '',
|
|
onSuccess,
|
|
}: UseHookNameParams = {}): UseHookNameReturn {
|
|
// 2.1 Estado
|
|
const [data, setData] = useState<Data | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
// 2.2 Efectos
|
|
useEffect(() => {
|
|
// Logica de efecto
|
|
}, [/* dependencias */]);
|
|
|
|
// 2.3 Callbacks
|
|
const refetch = useCallback(() => {
|
|
// Logica de refetch
|
|
}, [/* dependencias */]);
|
|
|
|
// 2.4 Return
|
|
return {
|
|
data,
|
|
isLoading,
|
|
error,
|
|
refetch,
|
|
};
|
|
}
|
|
```
|
|
|
|
### 2.2 Archivo de Hook Simple
|
|
```typescript
|
|
// hooks/useDebounce.ts
|
|
import { useState, useEffect } from 'react';
|
|
|
|
export function useDebounce<T>(value: T, delay: number = 300): T {
|
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedValue(value);
|
|
}, delay);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [value, delay]);
|
|
|
|
return debouncedValue;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Estructura de Carpetas
|
|
|
|
### 3.1 Hooks Compartidos
|
|
```
|
|
shared/
|
|
└── hooks/
|
|
├── index.ts # Barrel export
|
|
├── useDebounce.ts
|
|
├── useLocalStorage.ts
|
|
├── useMediaQuery.ts
|
|
├── useClickOutside.ts
|
|
└── useAsync.ts
|
|
```
|
|
|
|
### 3.2 Hooks por Feature
|
|
```
|
|
features/
|
|
└── auth/
|
|
└── hooks/
|
|
├── index.ts
|
|
├── useAuth.ts
|
|
├── useLogin.ts
|
|
└── useLogout.ts
|
|
```
|
|
|
|
### 3.3 Barrel Export
|
|
```typescript
|
|
// shared/hooks/index.ts
|
|
export { useDebounce } from './useDebounce';
|
|
export { useLocalStorage } from './useLocalStorage';
|
|
export { useMediaQuery } from './useMediaQuery';
|
|
export { useClickOutside } from './useClickOutside';
|
|
export { useAsync } from './useAsync';
|
|
|
|
// types
|
|
export type { UseAsyncReturn } from './useAsync';
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Patrones Comunes
|
|
|
|
### 4.1 State Management Hook
|
|
```typescript
|
|
// useToggle.ts
|
|
import { useState, useCallback } from 'react';
|
|
|
|
export function useToggle(initialValue: boolean = false) {
|
|
const [value, setValue] = useState(initialValue);
|
|
|
|
const toggle = useCallback(() => setValue(v => !v), []);
|
|
const setTrue = useCallback(() => setValue(true), []);
|
|
const setFalse = useCallback(() => setValue(false), []);
|
|
|
|
return { value, toggle, setTrue, setFalse };
|
|
}
|
|
|
|
// Uso
|
|
const { value: isOpen, toggle, setFalse: close } = useToggle();
|
|
```
|
|
|
|
### 4.2 Data Fetching Hook (con React Query)
|
|
```typescript
|
|
// useUserData.ts
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { fetchUser, updateUser } from '@/services/api/userApi';
|
|
import type { User, UpdateUserDto } from '@shared/types';
|
|
|
|
export function useUserData(userId: string) {
|
|
const queryClient = useQueryClient();
|
|
|
|
// Query
|
|
const query = useQuery({
|
|
queryKey: ['user', userId],
|
|
queryFn: () => fetchUser(userId),
|
|
enabled: !!userId,
|
|
staleTime: 5 * 60 * 1000, // 5 minutos
|
|
});
|
|
|
|
// Mutation
|
|
const mutation = useMutation({
|
|
mutationFn: (data: UpdateUserDto) => updateUser(userId, data),
|
|
onSuccess: (updatedUser) => {
|
|
queryClient.setQueryData(['user', userId], updatedUser);
|
|
},
|
|
});
|
|
|
|
return {
|
|
user: query.data,
|
|
isLoading: query.isLoading,
|
|
isError: query.isError,
|
|
error: query.error,
|
|
updateUser: mutation.mutate,
|
|
isUpdating: mutation.isPending,
|
|
};
|
|
}
|
|
|
|
// Uso
|
|
const { user, isLoading, updateUser } = useUserData(userId);
|
|
```
|
|
|
|
### 4.3 Form Hook
|
|
```typescript
|
|
// useForm.ts
|
|
import { useState, useCallback, ChangeEvent, FormEvent } from 'react';
|
|
|
|
interface UseFormOptions<T> {
|
|
initialValues: T;
|
|
validate?: (values: T) => Partial<Record<keyof T, string>>;
|
|
onSubmit: (values: T) => void | Promise<void>;
|
|
}
|
|
|
|
export function useForm<T extends Record<string, any>>({
|
|
initialValues,
|
|
validate,
|
|
onSubmit,
|
|
}: UseFormOptions<T>) {
|
|
const [values, setValues] = useState<T>(initialValues);
|
|
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const handleChange = useCallback((
|
|
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
) => {
|
|
const { name, value } = e.target;
|
|
setValues(prev => ({ ...prev, [name]: value }));
|
|
// Clear error when field changes
|
|
if (errors[name as keyof T]) {
|
|
setErrors(prev => ({ ...prev, [name]: undefined }));
|
|
}
|
|
}, [errors]);
|
|
|
|
const handleSubmit = useCallback(async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// Validate
|
|
if (validate) {
|
|
const validationErrors = validate(values);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
setErrors(validationErrors);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Submit
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onSubmit(values);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}, [values, validate, onSubmit]);
|
|
|
|
const reset = useCallback(() => {
|
|
setValues(initialValues);
|
|
setErrors({});
|
|
}, [initialValues]);
|
|
|
|
return {
|
|
values,
|
|
errors,
|
|
isSubmitting,
|
|
handleChange,
|
|
handleSubmit,
|
|
reset,
|
|
setValues,
|
|
setErrors,
|
|
};
|
|
}
|
|
|
|
// Uso
|
|
const form = useForm({
|
|
initialValues: { email: '', password: '' },
|
|
validate: (values) => {
|
|
const errors: Record<string, string> = {};
|
|
if (!values.email) errors.email = 'Requerido';
|
|
return errors;
|
|
},
|
|
onSubmit: async (values) => {
|
|
await login(values);
|
|
},
|
|
});
|
|
```
|
|
|
|
### 4.4 Storage Hook
|
|
```typescript
|
|
// useLocalStorage.ts
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
export function useLocalStorage<T>(
|
|
key: string,
|
|
initialValue: T
|
|
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
|
|
// Lazy initialization
|
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
try {
|
|
const item = window.localStorage.getItem(key);
|
|
return item ? JSON.parse(item) : initialValue;
|
|
} catch {
|
|
return initialValue;
|
|
}
|
|
});
|
|
|
|
// Update localStorage when value changes
|
|
useEffect(() => {
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(storedValue));
|
|
} catch (error) {
|
|
console.error(`Error saving to localStorage: ${key}`, error);
|
|
}
|
|
}, [key, storedValue]);
|
|
|
|
// Remove from localStorage
|
|
const remove = useCallback(() => {
|
|
try {
|
|
window.localStorage.removeItem(key);
|
|
setStoredValue(initialValue);
|
|
} catch (error) {
|
|
console.error(`Error removing from localStorage: ${key}`, error);
|
|
}
|
|
}, [key, initialValue]);
|
|
|
|
return [storedValue, setStoredValue, remove];
|
|
}
|
|
|
|
// Uso
|
|
const [theme, setTheme, clearTheme] = useLocalStorage('theme', 'light');
|
|
```
|
|
|
|
### 4.5 Event Listener Hook
|
|
```typescript
|
|
// useClickOutside.ts
|
|
import { useEffect, useRef, RefObject } from 'react';
|
|
|
|
export function useClickOutside<T extends HTMLElement>(
|
|
handler: () => void
|
|
): RefObject<T> {
|
|
const ref = useRef<T>(null);
|
|
|
|
useEffect(() => {
|
|
const listener = (event: MouseEvent | TouchEvent) => {
|
|
const el = ref.current;
|
|
if (!el || el.contains(event.target as Node)) {
|
|
return;
|
|
}
|
|
handler();
|
|
};
|
|
|
|
document.addEventListener('mousedown', listener);
|
|
document.addEventListener('touchstart', listener);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', listener);
|
|
document.removeEventListener('touchstart', listener);
|
|
};
|
|
}, [handler]);
|
|
|
|
return ref;
|
|
}
|
|
|
|
// Uso
|
|
const ref = useClickOutside<HTMLDivElement>(() => setIsOpen(false));
|
|
<div ref={ref}>...</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Integracion con React Query
|
|
|
|
### 5.1 Pattern para Queries
|
|
```typescript
|
|
// usePaginatedData.ts
|
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
|
|
|
interface UsePaginatedDataParams {
|
|
page: number;
|
|
limit: number;
|
|
filters?: Record<string, any>;
|
|
}
|
|
|
|
export function usePaginatedData<T>({
|
|
page,
|
|
limit,
|
|
filters,
|
|
}: UsePaginatedDataParams) {
|
|
return useQuery({
|
|
queryKey: ['data', { page, limit, ...filters }],
|
|
queryFn: () => fetchPaginatedData<T>({ page, limit, filters }),
|
|
placeholderData: keepPreviousData, // Mantener datos anteriores
|
|
staleTime: 30_000, // 30 segundos
|
|
});
|
|
}
|
|
```
|
|
|
|
### 5.2 Pattern para Mutations
|
|
```typescript
|
|
// useCreateItem.ts
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'react-hot-toast';
|
|
|
|
export function useCreateItem() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: createItem,
|
|
onSuccess: () => {
|
|
// Invalidar queries relacionadas
|
|
queryClient.invalidateQueries({ queryKey: ['items'] });
|
|
toast.success('Item creado exitosamente');
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(`Error: ${error.message}`);
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Error Handling en Hooks
|
|
|
|
### 6.1 Try-Catch Pattern
|
|
```typescript
|
|
export function useAsyncAction<T, P extends any[]>(
|
|
action: (...args: P) => Promise<T>
|
|
) {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [data, setData] = useState<T | null>(null);
|
|
|
|
const execute = useCallback(async (...args: P) => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await action(...args);
|
|
setData(result);
|
|
return result;
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error('Unknown error');
|
|
setError(error);
|
|
throw error;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [action]);
|
|
|
|
return { execute, isLoading, error, data };
|
|
}
|
|
```
|
|
|
|
### 6.2 Error Boundary Integration
|
|
```typescript
|
|
export function useErrorHandler() {
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
// Si hay error, lo propaga al Error Boundary mas cercano
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
|
|
const handleError = useCallback((error: Error) => {
|
|
setError(error);
|
|
}, []);
|
|
|
|
const clearError = useCallback(() => {
|
|
setError(null);
|
|
}, []);
|
|
|
|
return { handleError, clearError };
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Dependencies Array
|
|
|
|
### 7.1 Reglas de Dependencias
|
|
```typescript
|
|
// SIEMPRE incluir todas las dependencias usadas
|
|
|
|
// MAL - Falta dependencia
|
|
useEffect(() => {
|
|
fetchData(userId); // userId no esta en deps
|
|
}, []); // eslint warning!
|
|
|
|
// BIEN - Todas las dependencias
|
|
useEffect(() => {
|
|
fetchData(userId);
|
|
}, [userId]);
|
|
|
|
// useCallback con dependencias correctas
|
|
const handleClick = useCallback(() => {
|
|
doSomething(value);
|
|
}, [value]); // value incluido
|
|
```
|
|
|
|
### 7.2 Evitar Dependencias Inestables
|
|
```typescript
|
|
// MAL - Objeto nuevo en cada render
|
|
useEffect(() => {
|
|
fetchData(options);
|
|
}, [options]); // options es nuevo cada vez
|
|
|
|
// BIEN - Dependencias primitivas
|
|
useEffect(() => {
|
|
fetchData({ page, limit });
|
|
}, [page, limit]); // primitivos estables
|
|
|
|
// O usar useMemo para estabilizar
|
|
const stableOptions = useMemo(() => ({ page, limit }), [page, limit]);
|
|
useEffect(() => {
|
|
fetchData(stableOptions);
|
|
}, [stableOptions]);
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Testing de Hooks
|
|
|
|
### 8.1 Setup Basico
|
|
```typescript
|
|
// useCounter.test.ts
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { useCounter } from './useCounter';
|
|
|
|
describe('useCounter', () => {
|
|
it('should initialize with default value', () => {
|
|
const { result } = renderHook(() => useCounter(0));
|
|
expect(result.current.count).toBe(0);
|
|
});
|
|
|
|
it('should increment', () => {
|
|
const { result } = renderHook(() => useCounter(0));
|
|
|
|
act(() => {
|
|
result.current.increment();
|
|
});
|
|
|
|
expect(result.current.count).toBe(1);
|
|
});
|
|
|
|
it('should reset to initial value', () => {
|
|
const { result } = renderHook(() => useCounter(5));
|
|
|
|
act(() => {
|
|
result.current.increment();
|
|
result.current.reset();
|
|
});
|
|
|
|
expect(result.current.count).toBe(5);
|
|
});
|
|
});
|
|
```
|
|
|
|
### 8.2 Testing con React Query
|
|
```typescript
|
|
import { renderHook, waitFor } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { useUserData } from './useUserData';
|
|
|
|
const createWrapper = () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
return ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>
|
|
{children}
|
|
</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
describe('useUserData', () => {
|
|
it('should fetch user data', async () => {
|
|
const { result } = renderHook(() => useUserData('123'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.user).toBeDefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Anti-patrones a Evitar
|
|
|
|
### 9.1 NO: Llamar Hooks Condicionalmente
|
|
```typescript
|
|
// MAL - Viola reglas de hooks
|
|
if (condition) {
|
|
const [state] = useState(); // Error!
|
|
}
|
|
|
|
// BIEN - Hook siempre se llama
|
|
const [state] = useState();
|
|
if (condition) {
|
|
// usar state
|
|
}
|
|
```
|
|
|
|
### 9.2 NO: Efectos sin Cleanup
|
|
```typescript
|
|
// MAL - Memory leak potencial
|
|
useEffect(() => {
|
|
const subscription = subscribe(callback);
|
|
// Falta cleanup!
|
|
}, []);
|
|
|
|
// BIEN - Con cleanup
|
|
useEffect(() => {
|
|
const subscription = subscribe(callback);
|
|
return () => subscription.unsubscribe();
|
|
}, []);
|
|
```
|
|
|
|
### 9.3 NO: Dependencias Faltantes
|
|
```typescript
|
|
// MAL - eslint-disable para silenciar warning
|
|
useEffect(() => {
|
|
doSomething(value);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []); // NO hacer esto!
|
|
|
|
// BIEN - Incluir o refactorizar
|
|
useEffect(() => {
|
|
doSomething(value);
|
|
}, [value]);
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Checklist de Creacion de Hook
|
|
|
|
Antes de crear un nuevo custom hook:
|
|
|
|
1. [ ] Verificar que no existe hook similar
|
|
2. [ ] Nombre empieza con `use`
|
|
3. [ ] Parametros tipados con interface
|
|
4. [ ] Return tipado con interface o inline
|
|
5. [ ] Documentacion JSDoc con @example
|
|
6. [ ] Dependencies array completo
|
|
7. [ ] Cleanup en useEffect si necesario
|
|
8. [ ] Al menos 1 test basico
|
|
9. [ ] Exportar en barrel file
|
|
|
|
---
|
|
|
|
## 11. Referencias
|
|
|
|
- **Componentes:** `docs/95-guias-desarrollo/frontend/COMPONENT-PATTERNS.md`
|
|
- **Types:** `docs/95-guias-desarrollo/frontend/TYPES-CONVENTIONS.md`
|
|
- **React Hooks:** https://react.dev/reference/react/hooks
|
|
- **React Query:** https://tanstack.com/query/latest
|
|
|
|
---
|
|
|
|
## 12. Changelog
|
|
|
|
| Version | Fecha | Cambios |
|
|
|---------|-------|---------|
|
|
| 1.0 | 2025-11-29 | Creacion inicial |
|