feat: agregar hooks React HTTP genericos — issue 0017

4 tipos en frontend/types/core/: FetchState, MutationState, FormState, APIClientConfig.
9 funciones en frontend/functions/core/: api_client, http_cache, use_fetch,
use_mutation, use_infinite_scroll, use_form, use_debounced_search, use_sse, use_websocket.
Zero dependencias externas — solo React + fetch nativo. Cache in-memory con SWR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 02:03:10 +02:00
parent 092f14eff0
commit 94be3b62e7
27 changed files with 2156 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
---
name: api_client
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "apiClient(config: APIClientConfig): { get<T>(path, params?): Promise<T>; post<T>(path, body?): Promise<T>; put<T>(path, body?): Promise<T>; patch<T>(path, body?): Promise<T>; del<T>(path): Promise<T> }"
description: "Factory HTTP que retorna un cliente con get/post/put/patch/del. Usa fetch nativo con base_url+headers, parsea JSON automaticamente, llama on_error/on_unauthorized segun status code. Zero dependencias externas."
tags: [http, fetch, client, rest, api, factory, core]
uses_functions: []
uses_types: [APIClientConfig_ts_core]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: config
desc: "Configuracion: base_url (URL base sin slash final), headers opcionales, callbacks on_error y on_unauthorized"
output: "Objeto con metodos get/post/put/patch/del que hacen fetch con la config aplicada"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/api_client.ts"
---
## Ejemplo
```typescript
import { apiClient } from './api_client'
const api = apiClient({
base_url: 'http://localhost:8484/api',
headers: { 'X-Client': 'dashboard' },
on_unauthorized: () => (window.location.href = '/login'),
})
const users = await api.get<User[]>('/users')
const user = await api.post<User>('/users', { name: 'Lucas', email: 'lucas@example.com' })
await api.del('/users/123')
```
## Con parametros de query
```typescript
const results = await api.get<User[]>('/users', { search: 'lucas', role: 'admin' })
// GET /users?search=lucas&role=admin
```
## Notas
- `del` es un alias de DELETE para evitar colision con la keyword `delete` de JS
- Si la respuesta no es `application/json`, retorna el texto crudo como `T`
- `on_unauthorized` se llama ANTES de `on_error` en status 401
- Los errores de red (sin conexion, CORS) tambien llaman a `on_error`
- Creado una vez a nivel de modulo/app, pasado a hooks via `fetch_fn`
+88
View File
@@ -0,0 +1,88 @@
import type { APIClientConfig } from '../../types/core/api_client_config'
/** Metodos que retorna el factory api_client. */
export interface APIClient {
get: <T>(path: string, params?: Record<string, string>) => Promise<T>
post: <T>(path: string, body?: unknown) => Promise<T>
put: <T>(path: string, body?: unknown) => Promise<T>
patch: <T>(path: string, body?: unknown) => Promise<T>
del: <T>(path: string) => Promise<T>
}
/**
* Factory que crea un cliente HTTP configurado con base_url y headers fijos.
* Parsea JSON automaticamente. Llama a on_error/on_unauthorized segun el status.
*/
export function apiClient(config: APIClientConfig): APIClient {
const { base_url, headers: baseHeaders = {}, on_error, on_unauthorized } = config
const buildUrl = (path: string, params?: Record<string, string>): string => {
const url = `${base_url}${path}`
if (!params || Object.keys(params).length === 0) return url
const qs = new URLSearchParams(params).toString()
return `${url}?${qs}`
}
const buildHeaders = (extra: Record<string, string> = {}): HeadersInit => ({
'Accept': 'application/json',
...baseHeaders,
...extra,
})
const handleResponse = async <T>(res: Response, url: string, init?: RequestInit): Promise<T> => {
if (res.status === 401) {
on_unauthorized?.()
const err = new Error(`Unauthorized: ${url}`)
on_error?.(err, url, init)
throw err
}
if (!res.ok) {
const text = await res.text().catch(() => '')
const err = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
on_error?.(err, url, init)
throw err
}
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
return res.json() as Promise<T>
}
return res.text() as unknown as T
}
const request = async <T>(
method: string,
path: string,
body?: unknown,
params?: Record<string, string>,
): Promise<T> => {
const url = buildUrl(path, params)
const hasBody = body !== undefined
const init: RequestInit = {
method,
headers: buildHeaders(hasBody ? { 'Content-Type': 'application/json' } : {}),
body: hasBody ? JSON.stringify(body) : undefined,
}
try {
const res = await fetch(url, init)
return handleResponse<T>(res, url, init)
} catch (err) {
if (err instanceof Error && err.message.startsWith('HTTP ')) throw err
if (err instanceof Error && err.message.startsWith('Unauthorized')) throw err
const error = err instanceof Error ? err : new Error(String(err))
on_error?.(error, url, init)
throw error
}
}
return {
get: <T>(path: string, params?: Record<string, string>) =>
request<T>('GET', path, undefined, params),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
del: <T>(path: string) => request<T>('DELETE', path),
}
}
+41
View File
@@ -0,0 +1,41 @@
---
name: http_cache
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "cacheGet<T>(key: string): T | null; cacheSet<T>(key: string, data: T): void; cacheHas(key: string): boolean; cacheIsStale(key: string, staleTime: number): boolean; cacheDelete(key: string): void; cacheInvalidatePrefix(prefix: string): void; cacheClear(): void; cacheSize(): number"
description: "Cache in-memory singleton para use_fetch. Map con funciones de get/set/has/isStale/invalidate por prefijo. Compartido entre todas las instancias de use_fetch."
tags: [cache, http, fetch, swr, singleton, memory]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: key
desc: "Clave de cache (string serializado desde url + params)"
- name: staleTime
desc: "Tiempo en ms antes de considerar una entrada stale"
output: "Modulo con funciones de lectura/escritura/invalidacion del cache in-memory compartido"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/http_cache.ts"
---
## Ejemplo
```typescript
import { cacheGet, cacheSet, cacheIsStale } from './http_cache'
cacheSet('users:list', [{ id: '1', name: 'Lucas' }])
const users = cacheGet<User[]>('users:list')
const stale = cacheIsStale('users:list', 30_000) // > 30s?
```
## Notas
Singleton de modulo — el `Map` vive mientras dure la sesion de la SPA. No se persiste en localStorage ni IndexedDB. Para invalidacion jerarquica, `cacheInvalidatePrefix('users')` elimina `users`, `users:list`, `users:123`, etc.
+55
View File
@@ -0,0 +1,55 @@
/** Cache in-memory compartido para use_fetch. Singleton de modulo. */
interface CacheEntry {
data: unknown
timestamp: number
}
const cache = new Map<string, CacheEntry>()
/** Obtener dato del cache. Retorna null si no existe. */
export function cacheGet<T>(key: string): T | null {
const entry = cache.get(key)
return entry !== undefined ? (entry.data as T) : null
}
/** Guardar dato en el cache. */
export function cacheSet<T>(key: string, data: T): void {
cache.set(key, { data, timestamp: Date.now() })
}
/** Verificar si existe una entrada en el cache. */
export function cacheHas(key: string): boolean {
return cache.has(key)
}
/** Verificar si una entrada esta stale (supera staleTime en ms). */
export function cacheIsStale(key: string, staleTime: number): boolean {
const entry = cache.get(key)
if (!entry) return true
return Date.now() - entry.timestamp > staleTime
}
/** Eliminar una entrada del cache. */
export function cacheDelete(key: string): void {
cache.delete(key)
}
/** Eliminar todas las entradas cuya key empiece con prefix. */
export function cacheInvalidatePrefix(prefix: string): void {
for (const key of cache.keys()) {
if (key === prefix || key.startsWith(prefix + ':')) {
cache.delete(key)
}
}
}
/** Vaciar el cache completo. */
export function cacheClear(): void {
cache.clear()
}
/** Numero de entradas en cache. */
export function cacheSize(): number {
return cache.size
}
@@ -0,0 +1,90 @@
---
name: use_debounced_search
kind: component
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "useDebouncedSearch<T>(opts: UseDebouncedSearchOptions<T>): UseDebouncedSearchResult<T>"
description: "Hook que combina input con debounce y fetch automatico. Espera N ms de inactividad tras el ultimo cambio antes de fetchear. Expone query, set_query, results, loading, error y clear."
tags: [search, debounce, hook, input, fetch, autocomplete, core, component]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [react]
params:
- name: fetch_fn
desc: "Funcion que recibe la query string y retorna Promise<T> con los resultados"
- name: delay
desc: "Ms de inactividad antes de ejecutar el fetch (default 300)"
- name: initial_query
desc: "Query inicial (default string vacio)"
- name: fetch_on_empty
desc: "Si hace fetch cuando la query es string vacio (default true)"
output: "Objeto con query, set_query, results, loading, error y clear"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/use_debounced_search.tsx"
props:
- name: fetch_fn
type: "(query: string) => Promise<T>"
required: true
description: "Funcion de fetch que recibe la query"
- name: delay
type: "number"
required: false
description: "Ms de debounce (default 300)"
- name: initial_query
type: "string"
required: false
description: "Query inicial (default '')"
- name: fetch_on_empty
type: "boolean"
required: false
description: "Fetch cuando query es vacio (default true)"
emits: []
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { useDebouncedSearch } from '@/functions/core/use_debounced_search'
import { apiClient } from '@/functions/core/api_client'
const api = apiClient({ base_url: 'http://localhost:8484/api' })
interface User { id: string; name: string; email: string }
function UserSearch() {
const { query, set_query, results, loading } = useDebouncedSearch<User[]>({
fetch_fn: (q) => api.get('/users/search', { q }),
delay: 300,
fetch_on_empty: false, // No buscar cuando el input esta vacio
})
return (
<div>
<input
value={query}
onChange={(e) => set_query(e.currentTarget.value)}
placeholder="Buscar usuarios..."
/>
{loading && <span>Buscando...</span>}
{results?.map((u) => <div key={u.id}>{u.name}</div>)}
</div>
)
}
```
## Notas
- Cancela el timer anterior en cada keystroke — solo ejecuta el fetch tras N ms de silencio
- `clear()` cancela el timer pendiente y limpia estado
- `fetch_on_empty: false` es util para autocomplete (no buscar con input vacio)
- `fetch_on_empty: true` (default) es util para filtros (mostrar todos sin query)
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface UseDebouncedSearchOptions<T> {
/** Funcion de fetch que recibe la query y retorna resultados */
fetch_fn: (query: string) => Promise<T>
/** Delay en ms antes de ejecutar la busqueda tras el ultimo cambio (default 300) */
delay?: number
/** Query inicial (default "") */
initial_query?: string
/** Si false no hace fetch cuando query es empty string (default true = fetch con "") */
fetch_on_empty?: boolean
}
export interface UseDebouncedSearchResult<T> {
/** Valor actual del input de busqueda */
query: string
/** Actualizar la query (dispara el debounce) */
set_query: (q: string) => void
/** Resultados de la ultima busqueda exitosa */
results: T | null
/** Si hay una busqueda en curso */
loading: boolean
/** Error si la ultima busqueda fallo */
error: Error | null
/** Limpiar query y resultados */
clear: () => void
}
/**
* Hook que combina un input con debounce y fetch automatico.
* Espera N ms de inactividad tras el ultimo cambio de query antes de hacer la peticion.
*/
export function useDebouncedSearch<T>(
opts: UseDebouncedSearchOptions<T>,
): UseDebouncedSearchResult<T> {
const { fetch_fn, delay = 300, initial_query = '', fetch_on_empty = true } = opts
const [query, setQuery] = useState(initial_query)
const [results, setResults] = useState<T | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const mountedRef = useRef(true)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
useEffect(() => {
if (!fetch_on_empty && query === '') {
setResults(null)
setLoading(false)
return
}
setLoading(true)
setError(null)
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(async () => {
try {
const data = await fetch_fn(query)
if (!mountedRef.current) return
setResults(data)
} catch (err) {
if (!mountedRef.current) return
setError(err instanceof Error ? err : new Error(String(err)))
} finally {
if (mountedRef.current) setLoading(false)
}
}, delay)
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
}
}, [query, delay, fetch_on_empty]) // eslint-disable-line react-hooks/exhaustive-deps
const setQueryDebounced = useCallback((q: string) => {
setQuery(q)
}, [])
const clear = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
setQuery('')
setResults(null)
setLoading(false)
setError(null)
}, [])
return { query, set_query: setQueryDebounced, results, loading, error, clear }
}
+112
View File
@@ -0,0 +1,112 @@
---
name: use_fetch
kind: component
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "useFetch<T>(opts: UseFetchOptions<T>): FetchState<T>"
description: "Hook GET con cache in-memory stale-while-revalidate. Muestra datos cacheados inmediatamente y refetchea en background. Soporta refetch_interval, refetch_on_focus, stale_time y retry."
tags: [fetch, hook, cache, swr, get, http, core, component]
uses_functions: [http_cache_ts_core]
uses_types: [FetchState_ts_core]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [react]
params:
- name: url
desc: "URL a fetchear (string). Actua como cache key."
- name: fetch_fn
desc: "Funcion de fetch alternativa que recibe la URL (ej: api.get de api_client). Si se omite usa fetch nativo."
- name: enabled
desc: "Si false no hace fetch automatico al montar (default true)"
- name: stale_time
desc: "Tiempo en ms antes de considerar datos stale y refetchear (default 30000)"
- name: refetch_interval
desc: "Intervalo en ms para refetch automatico. 0 desactiva (default 0)"
- name: refetch_on_focus
desc: "Si refetchea al recuperar foco de la ventana (default true)"
- name: retry
desc: "Numero de reintentos ante error (default 0)"
- name: retry_delay
desc: "Delay en ms entre reintentos (default 1000)"
output: "FetchState<T> con data, error, loading, is_stale y refetch"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/use_fetch.tsx"
props:
- name: url
type: "string"
required: false
description: "URL a fetchear"
- name: fetch_fn
type: "(url: string) => Promise<T>"
required: false
description: "Funcion de fetch alternativa (ej: api.get)"
- name: enabled
type: "boolean"
required: false
description: "Habilitar fetch automatico (default true)"
- name: stale_time
type: "number"
required: false
description: "Ms antes de considerar datos stale (default 30000)"
- name: refetch_interval
type: "number"
required: false
description: "Ms entre refetches automaticos (0 = off)"
- name: refetch_on_focus
type: "boolean"
required: false
description: "Refetch al recuperar foco (default true)"
- name: retry
type: "number"
required: false
description: "Reintentos ante error (default 0)"
- name: retry_delay
type: "number"
required: false
description: "Ms entre reintentos (default 1000)"
emits: []
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { useFetch } from '@/functions/core/use_fetch'
import { apiClient } from '@/functions/core/api_client'
const api = apiClient({ base_url: 'http://localhost:8484/api' })
function UsersList() {
const { data: users, loading, error, is_stale, refetch } = useFetch<User[]>({
url: '/users',
fetch_fn: api.get,
stale_time: 10_000,
refetch_on_focus: true,
})
if (loading && !users) return <div>Cargando...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
{is_stale && <span>Actualizando...</span>}
{users?.map((u) => <div key={u.id}>{u.name}</div>)}
<button onClick={refetch}>Refrescar</button>
</div>
)
}
```
## Notas
- La URL actua como cache key — misma URL = mismos datos cacheados
- Stale-while-revalidate: si hay datos en cache y son stale, los muestra (`is_stale: true`) y refetchea en background
- Si no se pasa `fetch_fn`, usa `fetch` nativo con `Accept: application/json`
- Usar `enabled: false` para fetch condicional (ej: cuando falta un parametro)
+136
View File
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { cacheGet, cacheHas, cacheIsStale, cacheSet } from './http_cache'
import type { FetchState } from '../../types/core/fetch_state'
export interface UseFetchOptions<T> {
/** URL a fetchear, o funcion que retorna una Promise con los datos */
url?: string
/** Funcion de fetch alternativa (ej: api.get de api_client). Recibe la URL. */
fetch_fn?: (url: string) => Promise<T>
/** Si false, no hace fetch automatico al montar (default true) */
enabled?: boolean
/** Tiempo en ms antes de considerar datos stale (default 30000) */
stale_time?: number
/** Intervalo en ms para refetch automatico (0 = desactivado) */
refetch_interval?: number
/** Si refetchea al recuperar foco de la ventana (default true) */
refetch_on_focus?: boolean
/** Numero de reintentos ante error (default 0) */
retry?: number
/** Delay en ms entre reintentos (default 1000) */
retry_delay?: number
}
/**
* Hook GET con cache in-memory stale-while-revalidate.
* Muestra datos cacheados inmediatamente mientras refetchea en background.
*/
export function useFetch<T>(opts: UseFetchOptions<T>): FetchState<T> {
const {
url = '',
fetch_fn,
enabled = true,
stale_time = 30_000,
refetch_interval = 0,
refetch_on_focus = true,
retry = 0,
retry_delay = 1000,
} = opts
const cacheKey = url
type FetchInternalState = Omit<FetchState<T>, 'refetch'>
const [state, setState] = useState<FetchInternalState>(() => ({
data: cacheHas(cacheKey) ? cacheGet<T>(cacheKey) : null,
error: null,
loading: enabled && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time)),
is_stale: cacheHas(cacheKey) && cacheIsStale(cacheKey, stale_time),
}))
const mountedRef = useRef(true)
const retryCountRef = useRef(0)
const doFetch = useCallback(async (): Promise<T> => {
if (!mountedRef.current) throw new Error('unmounted')
setState((s: FetchInternalState) => ({ ...s, loading: true, error: null }))
try {
const data = fetch_fn
? await fetch_fn(url)
: await fetch(url, { headers: { Accept: 'application/json' } }).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<T>
})
if (!mountedRef.current) throw new Error('unmounted')
cacheSet(cacheKey, data)
retryCountRef.current = 0
setState({ data, loading: false, error: null, is_stale: false })
return data
} catch (err) {
if (!mountedRef.current) throw err
if (retryCountRef.current < retry) {
retryCountRef.current += 1
await new Promise((resolve) => setTimeout(resolve, retry_delay))
return doFetch()
}
const error = err instanceof Error ? err : new Error(String(err))
setState((s: FetchInternalState) => ({ ...s, loading: false, error }))
throw error
}
}, [url, fetch_fn, cacheKey, stale_time, retry, retry_delay])
// Fetch inicial
useEffect(() => {
mountedRef.current = true
if (enabled && url && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time))) {
doFetch().catch(() => {})
} else if (enabled && url && cacheHas(cacheKey)) {
// Datos en cache no stale — actualizar estado si es necesario
setState((s: FetchInternalState) => ({
...s,
data: cacheGet<T>(cacheKey),
loading: false,
is_stale: false,
}))
}
return () => {
mountedRef.current = false
}
}, [enabled, url, stale_time]) // eslint-disable-line react-hooks/exhaustive-deps
// Refetch por intervalo
useEffect(() => {
if (!refetch_interval || !enabled || !url) return
const id = setInterval(() => {
doFetch().catch(() => {})
}, refetch_interval)
return () => clearInterval(id)
}, [refetch_interval, enabled, url, doFetch])
// Refetch al recuperar foco
useEffect(() => {
if (!refetch_on_focus || !enabled || !url) return
const handler = () => {
if (cacheIsStale(cacheKey, stale_time)) {
doFetch().catch(() => {})
}
}
window.addEventListener('focus', handler)
return () => window.removeEventListener('focus', handler)
}, [refetch_on_focus, enabled, url, cacheKey, stale_time, doFetch])
return { ...state, refetch: doFetch }
}
+94
View File
@@ -0,0 +1,94 @@
---
name: use_form
kind: component
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "useForm<T extends Record<string, unknown>>(opts: UseFormOptions<T>): UseFormResult<T>"
description: "Hook de formulario con validacion sincrona, touched tracking y submit handler. Minimalista: set_field, set_fields, validate, submit, reset. Para formularios simples sin dependencia de Mantine."
tags: [form, hook, validation, touched, submit, core, component]
uses_functions: []
uses_types: [FormState_ts_core]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [react]
params:
- name: initial_values
desc: "Valores iniciales del formulario tipados con T"
- name: validate
desc: "Funcion de validacion sincrona: recibe values, retorna errores por campo (undefined = campo valido)"
output: "UseFormResult<T> con values, errors, touched, is_valid, is_submitting, set_field, set_fields, validate, submit y reset"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/use_form.tsx"
props:
- name: initial_values
type: "T"
required: true
description: "Valores iniciales del formulario"
- name: validate
type: "(values: T) => Partial<Record<keyof T, string | undefined>>"
required: false
description: "Funcion de validacion sincrona"
emits: []
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { useForm } from '@/functions/core/use_form'
interface LoginForm {
email: string
password: string
}
function LoginPage() {
const form = useForm<LoginForm>({
initial_values: { email: '', password: '' },
validate: (values) => ({
email: !values.email.includes('@') ? 'Email invalido' : undefined,
password: values.password.length < 8 ? 'Minimo 8 caracteres' : undefined,
}),
})
return (
<form onSubmit={(e) => {
e.preventDefault()
form.submit(async (values) => {
await loginApi(values.email, values.password)
})
}}>
<input
value={form.values.email}
onChange={(e) => form.set_field('email', e.currentTarget.value)}
/>
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input
type="password"
value={form.values.password}
onChange={(e) => form.set_field('password', e.currentTarget.value)}
/>
{form.touched.password && form.errors.password && <span>{form.errors.password}</span>}
<button type="submit" disabled={form.is_submitting || !form.is_valid}>
{form.is_submitting ? 'Cargando...' : 'Entrar'}
</button>
</form>
)
}
```
## Notas
- `touched` permite mostrar errores solo despues de que el usuario modifica un campo
- `submit(handler)` llama a `validate()` primero y solo ejecuta el handler si pasa
- `is_valid` es true si no hay errores (incluso antes de tocar campos)
- Para formularios complejos con validacion async, arrays de campos o schemas zod, usar `@mantine/form`
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useState } from 'react'
import type { FormState } from '../../types/core/form_state'
export interface UseFormOptions<T extends Record<string, unknown>> {
/** Valores iniciales del formulario */
initial_values: T
/** Funcion de validacion sincrona. Retorna errores por campo (undefined = sin error). */
validate?: (values: T) => Partial<Record<keyof T, string | undefined>>
}
/** Resultado del hook use_form, extiende FormState con metodos de control */
export interface UseFormResult<T extends Record<string, unknown>> extends FormState<T> {
/** Actualizar un campo individual */
set_field: <K extends keyof T>(name: K, value: T[K]) => void
/** Actualizar multiples campos a la vez */
set_fields: (partial: Partial<T>) => void
/** Ejecutar validacion manualmente. Retorna true si pasa. */
validate: () => boolean
/** Ejecutar validacion y llamar al handler solo si pasa */
submit: (handler: (values: T) => Promise<void> | void) => Promise<void>
/** Resetear al estado inicial */
reset: () => void
}
/**
* Hook para formularios con validacion sincrona, touched tracking y submit handler.
* Minimalista: no reemplaza @mantine/form. Para formularios simples sin Mantine.
*/
export function useForm<T extends Record<string, unknown>>(
opts: UseFormOptions<T>,
): UseFormResult<T> {
const { initial_values, validate: validateFn } = opts
const [values, setValues] = useState<T>(initial_values)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const runValidation = useCallback(
(vals: T): Partial<Record<keyof T, string>> => {
if (!validateFn) return {}
const result = validateFn(vals)
const filtered: Partial<Record<keyof T, string>> = {}
for (const key in result) {
const val = result[key as keyof T]
if (val !== undefined) {
filtered[key as keyof T] = val
}
}
return filtered
},
[validateFn],
)
const setField = useCallback(
<K extends keyof T>(name: K, value: T[K]) => {
setValues((prev: T) => {
const next = { ...prev, [name]: value }
if (validateFn) {
const newErrors = runValidation(next)
setErrors(newErrors)
}
return next
})
setTouched((prev: Partial<Record<keyof T, boolean>>) => ({ ...prev, [name]: true }))
},
[validateFn, runValidation],
)
const setFields = useCallback(
(partial: Partial<T>) => {
setValues((prev: T) => {
const next = { ...prev, ...partial }
if (validateFn) {
const newErrors = runValidation(next)
setErrors(newErrors)
}
return next
})
const newTouched: Partial<Record<keyof T, boolean>> = {}
for (const key in partial) {
newTouched[key as keyof T] = true
}
setTouched((prev: Partial<Record<keyof T, boolean>>) => ({ ...prev, ...newTouched }))
},
[validateFn, runValidation],
)
const validate = useCallback((): boolean => {
const newErrors = runValidation(values)
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values, runValidation])
const submit = useCallback(
async (handler: (values: T) => Promise<void> | void): Promise<void> => {
const newErrors = runValidation(values)
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setIsSubmitting(true)
try {
await handler(values)
} finally {
setIsSubmitting(false)
}
},
[values, runValidation],
)
const reset = useCallback(() => {
setValues(initial_values)
setErrors({})
setTouched({})
setIsSubmitting(false)
}, [initial_values])
const isValid = Object.keys(errors).length === 0
return {
values,
errors,
touched,
is_valid: isValid,
is_submitting: isSubmitting,
set_field: setField,
set_fields: setFields,
validate,
submit,
reset,
}
}
@@ -0,0 +1,104 @@
---
name: use_infinite_scroll
kind: component
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "useInfiniteScroll<T>(opts: UseInfiniteScrollOptions<T>): UseInfiniteScrollResult<T>"
description: "Hook de paginacion infinita cursor-based y offset-based. Concatena paginas en array plano. Expone load_more, has_more y reset. Zero dependencias externas."
tags: [infinite, scroll, pagination, cursor, hook, http, core, component]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [react]
params:
- name: fetch_fn
desc: "Funcion que recibe el cursor actual (string, number o null) y retorna Promise<{items, next_cursor}>"
- name: initial_cursor
desc: "Cursor inicial, null para empezar desde el principio (default null)"
- name: enabled
desc: "Si hace fetch automatico al montar (default true)"
output: "Objeto con data (array concatenado), loading, error, has_more, load_more() y reset()"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/use_infinite_scroll.tsx"
props:
- name: fetch_fn
type: "(cursor: string | number | null) => Promise<InfiniteScrollPage<T>>"
required: true
description: "Funcion de fetch que recibe cursor y retorna pagina con items y next_cursor"
- name: initial_cursor
desc: "Cursor inicial (default null)"
type: "string | number | null"
required: false
description: "Cursor/offset inicial"
- name: enabled
type: "boolean"
required: false
description: "Fetch automatico al montar (default true)"
emits: []
has_state: true
framework: react
variant: [default]
---
## Ejemplo cursor-based
```tsx
import { useInfiniteScroll } from '@/functions/core/use_infinite_scroll'
import { apiClient } from '@/functions/core/api_client'
const api = apiClient({ base_url: 'http://localhost:8484/api' })
function EventsList() {
const { data, loading, has_more, load_more } = useInfiniteScroll<Event>({
fetch_fn: async (cursor) => {
const res = await api.get<{ items: Event[]; next_cursor: string | null }>(
'/events',
cursor ? { after: String(cursor) } : undefined,
)
return { items: res.items, next_cursor: res.next_cursor }
},
})
return (
<div>
{data.map((e) => <div key={e.id}>{e.name}</div>)}
{has_more && (
<button disabled={loading} onClick={load_more}>
{loading ? 'Cargando...' : 'Cargar mas'}
</button>
)}
</div>
)
}
```
## Ejemplo offset-based
```tsx
const { data, load_more, has_more } = useInfiniteScroll<Product>({
fetch_fn: async (cursor) => {
const page = typeof cursor === 'number' ? cursor : 0
const items = await api.get<Product[]>('/products', {
offset: String(page * 20),
limit: '20',
})
return {
items,
next_cursor: items.length === 20 ? page + 1 : null,
}
},
initial_cursor: 0,
})
```
## Notas
- `InfiniteScrollPage.next_cursor = null` indica que no hay mas paginas
- `reset()` vuelve al cursor inicial y refetchea desde el principio
- `load_more` es no-op si `loading` o `!has_more`
@@ -0,0 +1,106 @@
import { useCallback, useRef, useState } from 'react'
export interface UseInfiniteScrollOptions<T> {
/** Funcion de fetch que recibe el cursor/pagina actual y retorna la pagina de datos */
fetch_fn: (cursor: string | number | null) => Promise<InfiniteScrollPage<T>>
/** Cursor inicial (null para empezar desde el principio) */
initial_cursor?: string | number | null
/** Si el primer fetch es automatico al montar (default true) */
enabled?: boolean
}
/** Pagina retornada por fetch_fn */
export interface InfiniteScrollPage<T> {
/** Datos de esta pagina */
items: T[]
/** Cursor para la siguiente pagina. null/undefined = no hay mas paginas. */
next_cursor?: string | number | null
}
/** Estado del hook de paginacion infinita */
export interface UseInfiniteScrollResult<T> {
/** Todos los items concatenados de todas las paginas */
data: T[]
/** Si hay una peticion en curso */
loading: boolean
/** Error si la ultima peticion fallo */
error: Error | null
/** Si hay mas paginas disponibles */
has_more: boolean
/** Cargar la siguiente pagina */
load_more: () => void
/** Resetear al estado inicial y refetchear desde el principio */
reset: () => void
}
/**
* Hook para paginacion infinita cursor-based y offset-based.
* Concatena paginas en un solo array. Expone load_more, has_more y reset.
*/
export function useInfiniteScroll<T>(
opts: UseInfiniteScrollOptions<T>,
): UseInfiniteScrollResult<T> {
const { fetch_fn, initial_cursor = null, enabled = true } = opts
const [data, setData] = useState<T[]>([])
const [loading, setLoading] = useState(enabled)
const [error, setError] = useState<Error | null>(null)
const [hasMore, setHasMore] = useState(true)
const cursorRef = useRef<string | number | null>(initial_cursor)
const loadingRef = useRef(false)
const mountedRef = useRef(true)
const fetchPage = useCallback(
async (cursor: string | number | null): Promise<void> => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
setError(null)
try {
const page = await fetch_fn(cursor)
if (!mountedRef.current) return
setData((prev: T[]) => (cursor === initial_cursor ? page.items : [...prev, ...page.items]))
const nextCursor = page.next_cursor ?? null
cursorRef.current = nextCursor
setHasMore(nextCursor !== null && nextCursor !== undefined)
} catch (err) {
if (!mountedRef.current) return
setError(err instanceof Error ? err : new Error(String(err)))
} finally {
if (mountedRef.current) setLoading(false)
loadingRef.current = false
}
},
[fetch_fn, initial_cursor],
)
// Fetch inicial
const initializedRef = useRef(false)
if (!initializedRef.current && enabled) {
initializedRef.current = true
// eslint-disable-next-line @typescript-eslint/no-floating-promises
fetchPage(initial_cursor)
}
const loadMore = useCallback(() => {
if (!hasMore || loadingRef.current) return
fetchPage(cursorRef.current).catch(() => {})
}, [fetchPage, hasMore])
const reset = useCallback(() => {
cursorRef.current = initial_cursor
loadingRef.current = false
setData([])
setError(null)
setHasMore(true)
fetchPage(initial_cursor).catch(() => {})
}, [fetchPage, initial_cursor])
return { data, loading, error, has_more: hasMore, load_more: loadMore, reset }
}
+120
View File
@@ -0,0 +1,120 @@
---
name: use_mutation
kind: component
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "useMutation<TData, TVariables>(opts: UseMutationOptions<TData, TVariables>): MutationState<TData, TVariables>"
description: "Hook POST/PUT/DELETE con optimistic updates, invalidacion de cache, retry y callbacks completos. No ejecuta al montar — expone mutate y mutate_async."
tags: [mutation, hook, post, put, delete, http, optimistic, cache, core, component]
uses_functions: [http_cache_ts_core]
uses_types: [MutationState_ts_core]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [react]
params:
- name: mutation_fn
desc: "Funcion que ejecuta la mutacion, recibe variables y retorna Promise<TData>"
- name: on_mutate
desc: "Callback antes de la mutacion para optimistic updates. Retorna contexto de rollback."
- name: on_success
desc: "Callback al exito con data, variables y context"
- name: on_error
desc: "Callback al error con error, variables y context para rollback"
- name: on_settled
desc: "Callback siempre (exito o error)"
- name: invalidate_cache
desc: "Prefijos de cache a invalidar en exito (ej: 'users' invalida users, users:list, etc.)"
- name: retry
desc: "Numero de reintentos ante error (default 0)"
- name: retry_delay
desc: "Delay en ms entre reintentos (default 1000)"
output: "MutationState<TData, TVariables> con mutate, mutate_async, data, error, loading y reset"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/use_mutation.tsx"
props:
- name: mutation_fn
type: "(variables: TVariables) => Promise<TData>"
required: true
description: "Funcion que ejecuta la mutacion"
- name: on_mutate
type: "(variables: TVariables) => Promise<unknown> | unknown"
required: false
description: "Optimistic update antes de la mutacion"
- name: on_success
type: "(data: TData, variables: TVariables, context: unknown) => void"
required: false
description: "Callback al exito"
- name: on_error
type: "(error: Error, variables: TVariables, context: unknown) => void"
required: false
description: "Callback al error (para rollback de optimistic updates)"
- name: invalidate_cache
type: "string[]"
required: false
description: "Prefijos de cache a invalidar en exito"
- name: retry
type: "number"
required: false
description: "Reintentos ante error (default 0)"
- name: retry_delay
type: "number"
required: false
description: "Ms entre reintentos (default 1000)"
emits: [on_success, on_error, on_settled]
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { useMutation } from '@/functions/core/use_mutation'
import { apiClient } from '@/functions/core/api_client'
const api = apiClient({ base_url: 'http://localhost:8484/api' })
function DeleteButton({ userId }: { userId: string }) {
const { mutate: deleteUser, loading } = useMutation<void, string>({
mutation_fn: (id) => api.del(`/users/${id}`),
invalidate_cache: ['users'],
on_success: () => console.log('Eliminado'),
})
return (
<button disabled={loading} onClick={() => deleteUser(userId)}>
{loading ? 'Eliminando...' : 'Eliminar'}
</button>
)
}
```
## Optimistic update
```tsx
const { mutate } = useMutation<User, Partial<User>>({
mutation_fn: (data) => api.post('/users', data),
on_mutate: (newUser) => {
// Guardar snapshot para rollback
const prev = cacheGet<User[]>('users')
cacheSet('users', [...(prev ?? []), { ...newUser, id: 'temp' }])
return { prev }
},
on_error: (_err, _vars, context) => {
// Rollback
const ctx = context as { prev: User[] }
cacheSet('users', ctx.prev)
},
})
```
## Notas
- `mutate` es fire-and-forget (no lanza, silencia el error)
- `mutate_async` retorna Promise (puede rechazar — usar try/catch)
- `invalidate_cache` usa `cacheInvalidatePrefix` — invalida prefijos jerarquicos
+117
View File
@@ -0,0 +1,117 @@
import { useCallback, useRef, useState } from 'react'
import { cacheInvalidatePrefix } from './http_cache'
import type { MutationState } from '../../types/core/mutation_state'
export interface UseMutationOptions<TData, TVariables> {
/** Funcion que ejecuta la mutacion (POST/PUT/DELETE) */
mutation_fn: (variables: TVariables) => Promise<TData>
/** Callback antes de la mutacion — permite optimistic updates. Retorna contexto de rollback. */
on_mutate?: (variables: TVariables) => Promise<unknown> | unknown
/** Callback al exito */
on_success?: (data: TData, variables: TVariables, context: unknown) => void
/** Callback al error */
on_error?: (error: Error, variables: TVariables, context: unknown) => void
/** Callback siempre (exito o error) */
on_settled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void
/** Prefijos de cache a invalidar en exito (ej: ['users'] invalida users, users:list, etc.) */
invalidate_cache?: string[]
/** Numero de reintentos ante error (default 0) */
retry?: number
/** Delay en ms entre reintentos (default 1000) */
retry_delay?: number
}
/**
* Hook para operaciones de escritura POST/PUT/DELETE.
* No ejecuta al montar. Expone mutate() y mutate_async().
* Soporta optimistic updates via on_mutate y invalidacion de cache.
*/
export function useMutation<TData, TVariables = void>(
opts: UseMutationOptions<TData, TVariables>,
): MutationState<TData, TVariables> {
const {
mutation_fn,
on_mutate,
on_success,
on_error,
on_settled,
invalidate_cache = [],
retry = 0,
retry_delay = 1000,
} = opts
type MutationInternalState = {
status: 'idle' | 'loading' | 'success' | 'error'
data: TData | null
error: Error | null
}
const [state, setState] = useState<MutationInternalState>({ status: 'idle', data: null, error: null })
const retryCountRef = useRef(0)
const execute = useCallback(
async (variables: TVariables): Promise<TData> => {
setState((s: MutationInternalState) => ({ ...s, status: 'loading', error: null }))
let context: unknown
try {
if (on_mutate) {
context = await on_mutate(variables)
}
const data = await mutation_fn(variables)
retryCountRef.current = 0
setState({ status: 'success', data, error: null })
// Invalidar cache en exito
for (const prefix of invalidate_cache) {
cacheInvalidatePrefix(prefix)
}
on_success?.(data, variables, context)
on_settled?.(data, null, variables, context)
return data
} catch (err) {
if (retryCountRef.current < retry) {
retryCountRef.current += 1
await new Promise((resolve) => setTimeout(resolve, retry_delay))
return execute(variables)
}
const error = err instanceof Error ? err : new Error(String(err))
setState((s: MutationInternalState) => ({ ...s, status: 'error', error }))
on_error?.(error, variables, context)
on_settled?.(undefined, error, variables, context)
throw error
}
},
[mutation_fn, on_mutate, on_success, on_error, on_settled, invalidate_cache, retry, retry_delay],
)
const mutate = useCallback(
(variables: TVariables) => {
execute(variables).catch(() => {})
},
[execute],
)
const reset = useCallback(() => {
setState({ status: 'idle', data: null, error: null })
retryCountRef.current = 0
}, [])
return {
mutate,
mutate_async: execute,
data: state.data,
error: state.error,
loading: state.status === 'loading',
reset,
}
}
+104
View File
@@ -0,0 +1,104 @@
---
name: use_sse
kind: component
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "useSSE<T>(opts: UseSSEOptions<T>): UseSSEResult<T>"
description: "Hook para Server-Sent Events con auto-reconexion y backoff exponencial. Parsea eventos como JSON. Acumula todos los eventos en data[]. Expone last_event, status y close."
tags: [sse, server-sent-events, stream, hook, realtime, reconnect, core, component]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [react]
params:
- name: url
desc: "URL del endpoint SSE (ej: http://localhost:8484/api/logs/stream)"
- name: enabled
desc: "Si conecta automaticamente al montar (default true)"
- name: reconnect
desc: "Si reconecta automaticamente con backoff exponencial (default true)"
- name: reconnect_interval
desc: "Delay inicial de reconexion en ms (default 1000). Se duplica hasta max_reconnect_interval."
- name: max_reconnect_interval
desc: "Delay maximo de reconexion en ms (default 30000)"
- name: event_name
desc: "Nombre del evento SSE a escuchar (default 'message')"
- name: on_event
desc: "Callback por cada evento recibido con el dato parseado"
output: "Objeto con data[], last_event, status (connecting/open/closed), error, close() y connect()"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/use_sse.tsx"
props:
- name: url
type: "string"
required: true
description: "URL del endpoint SSE"
- name: enabled
type: "boolean"
required: false
description: "Conectar al montar (default true)"
- name: reconnect
type: "boolean"
required: false
description: "Auto-reconectar (default true)"
- name: reconnect_interval
type: "number"
required: false
description: "Delay inicial reconexion en ms (default 1000)"
- name: max_reconnect_interval
type: "number"
required: false
description: "Delay maximo reconexion en ms (default 30000)"
- name: event_name
type: "string"
required: false
description: "Nombre evento SSE (default 'message')"
emits: [on_event, on_error]
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { useSSE } from '@/functions/core/use_sse'
interface LogEntry { level: string; msg: string; ts: number }
function LogViewer() {
const { data: logs, last_event, status, close } = useSSE<LogEntry>({
url: 'http://localhost:8484/api/logs/stream',
reconnect: true,
reconnect_interval: 2000,
})
return (
<div>
<span>Status: {status}</span>
<div style={{ overflow: 'auto', height: 400 }}>
{logs.map((log, i) => (
<div key={i} style={{ color: log.level === 'error' ? 'red' : 'inherit' }}>
[{log.level}] {log.msg}
</div>
))}
</div>
<button onClick={close}>Cerrar conexion</button>
</div>
)
}
```
## Notas
- El backoff exponencial duplica el delay hasta `max_reconnect_interval`
- Si el evento no es JSON valido, se guarda como string
- `close()` previene reconexion automatica
- `connect()` fuerza reconexion manual aunque haya sido cerrado manualmente
- `data` acumula todos los eventos de la sesion. Para limpiar, desmontar y remontar el componente.
+163
View File
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export type SSEStatus = 'connecting' | 'open' | 'closed'
export interface UseSSEOptions<T> {
/** URL del endpoint SSE */
url: string
/** Si conecta automaticamente al montar (default true) */
enabled?: boolean
/** Si reconecta automaticamente al perder la conexion (default true) */
reconnect?: boolean
/** Delay inicial de reconexion en ms (default 1000). Se duplica con backoff hasta max_reconnect_interval. */
reconnect_interval?: number
/** Delay maximo de reconexion en ms (default 30000) */
max_reconnect_interval?: number
/** Nombre del evento a escuchar (default 'message'). Para multiples eventos usar on_event. */
event_name?: string
/** Callback por evento recibido. Recibe el dato parseado. */
on_event?: (data: T) => void
/** Callback al error */
on_error?: (error: Event) => void
}
export interface UseSSEResult<T> {
/** Todos los eventos recibidos en esta sesion */
data: T[]
/** Ultimo evento recibido */
last_event: T | null
/** Estado de la conexion */
status: SSEStatus
/** Error de conexion si ocurrio */
error: Event | null
/** Cerrar la conexion manualmente */
close: () => void
/** Reconectar manualmente */
connect: () => void
}
/**
* Hook para Server-Sent Events con auto-reconexion y backoff exponencial.
* Parsea cada evento como JSON. Acumula todos los eventos en data[].
*/
export function useSSE<T>(opts: UseSSEOptions<T>): UseSSEResult<T> {
const {
url,
enabled = true,
reconnect = true,
reconnect_interval = 1000,
max_reconnect_interval = 30_000,
event_name = 'message',
on_event,
on_error,
} = opts
const [data, setData] = useState<T[]>([])
const [lastEvent, setLastEvent] = useState<T | null>(null)
const [status, setStatus] = useState<SSEStatus>('closed')
const [error, setError] = useState<Event | null>(null)
const esRef = useRef<EventSource | null>(null)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const currentIntervalRef = useRef(reconnect_interval)
const mountedRef = useRef(true)
const closedManuallyRef = useRef(false)
const clearReconnectTimer = () => {
if (reconnectTimerRef.current !== null) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}
const connect = useCallback(() => {
if (!mountedRef.current) return
// Cerrar conexion previa si existe
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
closedManuallyRef.current = false
currentIntervalRef.current = reconnect_interval
setStatus('connecting')
setError(null)
const es = new EventSource(url)
esRef.current = es
es.addEventListener(event_name, (e: MessageEvent) => {
if (!mountedRef.current) return
let parsed: T
try {
parsed = JSON.parse(e.data) as T
} catch {
parsed = e.data as unknown as T
}
setData((prev: T[]) => [...prev, parsed])
setLastEvent(parsed)
on_event?.(parsed)
// Reset backoff on successful message
currentIntervalRef.current = reconnect_interval
})
es.onopen = () => {
if (!mountedRef.current) return
setStatus('open')
currentIntervalRef.current = reconnect_interval
}
es.onerror = (e) => {
if (!mountedRef.current) return
setError(e)
on_error?.(e)
setStatus('closed')
es.close()
esRef.current = null
if (reconnect && !closedManuallyRef.current) {
const delay = Math.min(currentIntervalRef.current, max_reconnect_interval)
currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval)
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current && !closedManuallyRef.current) {
connect()
}
}, delay)
}
}
}, [url, event_name, reconnect, reconnect_interval, max_reconnect_interval, on_event, on_error])
const close = useCallback(() => {
closedManuallyRef.current = true
clearReconnectTimer()
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
setStatus('closed')
}, [])
useEffect(() => {
mountedRef.current = true
if (enabled) {
connect()
}
return () => {
mountedRef.current = false
clearReconnectTimer()
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
}
}, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps
return { data, last_event: lastEvent, status, error, close, connect }
}
+115
View File
@@ -0,0 +1,115 @@
---
name: use_websocket
kind: component
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "useWebSocket<TSend, TRecv>(opts: UseWebSocketOptions<TSend, TRecv>): UseWebSocketResult<TSend, TRecv>"
description: "Hook WebSocket con auto-reconexion y backoff exponencial. Serializa envios como JSON, parsea recepcion como JSON. Buffer de mensajes con limite configurable. Mensajes en vuelo se bufferean hasta reconectar."
tags: [websocket, ws, hook, realtime, reconnect, stream, core, component]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [react]
params:
- name: url
desc: "URL del WebSocket (ej: ws://localhost:8484/ws/chat)"
- name: enabled
desc: "Si conecta automaticamente al montar (default true)"
- name: reconnect
desc: "Si reconecta automaticamente con backoff exponencial (default true)"
- name: reconnect_interval
desc: "Delay inicial de reconexion en ms (default 1000)"
- name: max_reconnect_interval
desc: "Delay maximo de reconexion en ms (default 30000)"
- name: max_messages
desc: "Numero maximo de mensajes a mantener en buffer (default 100)"
- name: on_message
desc: "Callback por cada mensaje recibido con el dato parseado"
output: "Objeto con send, last_message, messages[], status (connecting/open/closing/closed), close() y connect()"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/use_websocket.tsx"
props:
- name: url
type: "string"
required: true
description: "URL del WebSocket"
- name: enabled
type: "boolean"
required: false
description: "Conectar al montar (default true)"
- name: reconnect
type: "boolean"
required: false
description: "Auto-reconectar (default true)"
- name: reconnect_interval
type: "number"
required: false
description: "Delay inicial reconexion en ms (default 1000)"
- name: max_reconnect_interval
type: "number"
required: false
description: "Delay maximo reconexion en ms (default 30000)"
- name: max_messages
type: "number"
required: false
description: "Tamano del buffer de mensajes (default 100)"
emits: [on_message, on_open, on_close, on_error]
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { useWebSocket } from '@/functions/core/use_websocket'
interface ChatMessage { user: string; text: string; ts: number }
interface SendMessage { text: string }
function Chat() {
const { send, messages, status } = useWebSocket<SendMessage, ChatMessage>({
url: 'ws://localhost:8484/ws/chat',
reconnect: true,
max_messages: 50,
})
const [input, setInput] = useState('')
return (
<div>
<div>Status: {status}</div>
<div style={{ height: 300, overflow: 'auto' }}>
{messages.map((msg, i) => (
<div key={i}><b>{msg.user}:</b> {msg.text}</div>
))}
</div>
<input
value={input}
disabled={status !== 'open'}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && input.trim()) {
send({ text: input })
setInput('')
}
}}
/>
</div>
)
}
```
## Notas
- Genericos separados: `TSend` para envio, `TRecv` para recepcion (pueden diferir)
- Mensajes enviados antes de `open` se bufferean y envian al conectar
- El buffer `messages` se limita a `max_messages` (FIFO, elimina el mas viejo)
- El backoff exponencial duplica el delay hasta `max_reconnect_interval`
- `close()` previene reconexion automatica; `connect()` fuerza reconexion manual
+198
View File
@@ -0,0 +1,198 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export type WebSocketStatus = 'connecting' | 'open' | 'closing' | 'closed'
export interface UseWebSocketOptions<TRecv> {
/** URL del WebSocket (ej: ws://localhost:8484/ws/chat) */
url: string
/** Si conecta automaticamente al montar (default true) */
enabled?: boolean
/** Si reconecta automaticamente al perder la conexion (default true) */
reconnect?: boolean
/** Delay inicial de reconexion en ms (default 1000) */
reconnect_interval?: number
/** Delay maximo de reconexion en ms (default 30000) */
max_reconnect_interval?: number
/** Numero maximo de mensajes a mantener en el buffer (default 100) */
max_messages?: number
/** Callback por cada mensaje recibido */
on_message?: (data: TRecv) => void
/** Callback al conectar */
on_open?: () => void
/** Callback al cerrar */
on_close?: (event: CloseEvent) => void
/** Callback al error */
on_error?: (error: Event) => void
}
export interface UseWebSocketResult<TSend, TRecv> {
/** Enviar un mensaje (serializado como JSON) */
send: (data: TSend) => void
/** Ultimo mensaje recibido */
last_message: TRecv | null
/** Buffer de mensajes recibidos (limitado a max_messages) */
messages: TRecv[]
/** Estado de la conexion */
status: WebSocketStatus
/** Cerrar la conexion manualmente */
close: () => void
/** Reconectar manualmente */
connect: () => void
}
/**
* Hook WebSocket con auto-reconexion y backoff exponencial.
* Serializa mensajes enviados como JSON. Parsea mensajes recibidos como JSON.
* Mantiene buffer de los ultimos N mensajes.
*/
export function useWebSocket<TSend, TRecv>(
opts: UseWebSocketOptions<TRecv>,
): UseWebSocketResult<TSend, TRecv> {
const {
url,
enabled = true,
reconnect = true,
reconnect_interval = 1000,
max_reconnect_interval = 30_000,
max_messages = 100,
on_message,
on_open,
on_close,
on_error,
} = opts
const [messages, setMessages] = useState<TRecv[]>([])
const [lastMessage, setLastMessage] = useState<TRecv | null>(null)
const [status, setStatus] = useState<WebSocketStatus>('closed')
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const currentIntervalRef = useRef(reconnect_interval)
const mountedRef = useRef(true)
const closedManuallyRef = useRef(false)
const pendingMessages = useRef<TSend[]>([])
const clearReconnectTimer = () => {
if (reconnectTimerRef.current !== null) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}
const connect = useCallback(() => {
if (!mountedRef.current) return
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
closedManuallyRef.current = false
currentIntervalRef.current = reconnect_interval
setStatus('connecting')
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
if (!mountedRef.current) return
setStatus('open')
currentIntervalRef.current = reconnect_interval
on_open?.()
// Enviar mensajes pendientes
for (const msg of pendingMessages.current) {
ws.send(JSON.stringify(msg))
}
pendingMessages.current = []
}
ws.onmessage = (e: MessageEvent) => {
if (!mountedRef.current) return
let parsed: TRecv
try {
parsed = JSON.parse(e.data as string) as TRecv
} catch {
parsed = e.data as unknown as TRecv
}
setLastMessage(parsed)
setMessages((prev: TRecv[]) => {
const next = [...prev, parsed]
return next.length > max_messages ? next.slice(next.length - max_messages) : next
})
on_message?.(parsed)
}
ws.onclose = (e) => {
if (!mountedRef.current) return
setStatus('closed')
wsRef.current = null
on_close?.(e)
if (reconnect && !closedManuallyRef.current) {
const delay = Math.min(currentIntervalRef.current, max_reconnect_interval)
currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval)
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current && !closedManuallyRef.current) {
connect()
}
}, delay)
}
}
ws.onerror = (e) => {
if (!mountedRef.current) return
on_error?.(e)
}
}, [url, reconnect, reconnect_interval, max_reconnect_interval, max_messages, on_message, on_open, on_close, on_error])
const close = useCallback(() => {
closedManuallyRef.current = true
clearReconnectTimer()
if (wsRef.current) {
setStatus('closing')
wsRef.current.close()
wsRef.current = null
}
setStatus('closed')
}, [])
const send = useCallback((data: TSend) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data))
} else {
// Buffer el mensaje para enviarlo cuando conecte
pendingMessages.current.push(data)
}
}, [])
useEffect(() => {
mountedRef.current = true
if (enabled) {
connect()
}
return () => {
mountedRef.current = false
clearReconnectTimer()
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
}
}, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps
return {
send,
last_message: lastMessage,
messages,
status,
close,
connect,
}
}
+43
View File
@@ -0,0 +1,43 @@
---
name: APIClientConfig
lang: ts
domain: core
version: "1.0.0"
algebraic: product
definition: |
interface APIClientConfig {
base_url: string
headers?: Record<string, string>
on_error?: (error: Error, url: string, init?: RequestInit) => void
on_unauthorized?: () => void
}
description: "Configuracion del cliente HTTP. Define base_url, headers fijos, callback de error global y callback de 401 Unauthorized."
tags: [api, client, config, http, fetch, auth]
uses_types: []
file_path: "frontend/types/core/api_client_config.ts"
---
## Campos
- `base_url` — prefijo de URL para todos los requests, sin slash final (ej: `"http://localhost:8484/api"`)
- `headers` — headers HTTP fijos que se mergeaan en cada peticion (ej: `{ Authorization: 'Bearer ...' }`)
- `on_error` — callback global llamado cuando cualquier peticion falla, recibe el error, la URL y el RequestInit
- `on_unauthorized` — callback llamado cuando el servidor responde 401, tipicamente redirige al login
## Uso
```typescript
import type { APIClientConfig } from '@/types/core/api_client_config'
import { apiClient } from '@/functions/core/api_client'
const config: APIClientConfig = {
base_url: 'http://localhost:8484/api',
headers: { 'X-Client': 'dashboard' },
on_unauthorized: () => (window.location.href = '/login'),
}
const api = apiClient(config)
```
## Notas
Pasado como primer argumento a `api_client_ts_core`. Los interceptors `on_error` y `on_unauthorized` son globales — aplican a todos los metodos (get, post, put, del, patch) del cliente creado.
+11
View File
@@ -0,0 +1,11 @@
/** Configuracion del cliente HTTP creado por api_client. */
export interface APIClientConfig {
/** URL base para todas las peticiones (ej: "http://localhost:8484/api"). Sin slash final. */
base_url: string
/** Headers fijos que se incluyen en todas las peticiones */
headers?: Record<string, string>
/** Callback global de error — llamado en cualquier peticion fallida */
on_error?: (error: Error, url: string, init?: RequestInit) => void
/** Callback llamado cuando el servidor responde 401 Unauthorized */
on_unauthorized?: () => void
}
+41
View File
@@ -0,0 +1,41 @@
---
name: FetchState
lang: ts
domain: core
version: "1.0.0"
algebraic: product
definition: |
interface FetchState<T> {
data: T | null
error: Error | null
loading: boolean
refetch: () => Promise<T>
is_stale: boolean
}
description: "Estado de un fetch GET con cache stale-while-revalidate. Incluye datos, error, loading, refetch y flag de staleness."
tags: [fetch, state, http, cache, swr, hook]
uses_types: []
file_path: "frontend/types/core/fetch_state.ts"
---
## Campos
- `data` — datos obtenidos, null hasta que llega la primera respuesta
- `error` — error de red o de parsing, null si no hubo error
- `loading` — true mientras hay una peticion en vuelo
- `refetch` — funcion para re-ejecutar la peticion manualmente
- `is_stale` — true cuando se muestran datos del cache mientras se refetchea en background
## Uso
```typescript
import type { FetchState } from '@/types/core/fetch_state'
function useMyHook(): FetchState<User[]> {
// ...
}
```
## Notas
Retornado por `use_fetch_ts_core`. El patron stale-while-revalidate permite mostrar datos viejos inmediatamente mientras se actualizan en background, mejorando la UX percibida.
+13
View File
@@ -0,0 +1,13 @@
/** Estado de un fetch GET con cache stale-while-revalidate. */
export interface FetchState<T> {
/** Datos obtenidos, null si todavia no se han cargado */
data: T | null
/** Error si la peticion fallo */
error: Error | null
/** Si la peticion esta en progreso */
loading: boolean
/** Re-ejecutar la peticion manualmente */
refetch: () => Promise<T>
/** Si los datos son stale (cache viejo mostrando mientras refetch en background) */
is_stale: boolean
}
+43
View File
@@ -0,0 +1,43 @@
---
name: FormState
lang: ts
domain: core
version: "1.0.0"
algebraic: product
definition: |
interface FormState<T extends Record<string, unknown>> {
values: T
errors: Partial<Record<keyof T, string>>
touched: Partial<Record<keyof T, boolean>>
is_valid: boolean
is_submitting: boolean
}
description: "Estado de un formulario con validacion sincrona, tracking de touched y flag de submit en progreso."
tags: [form, state, validation, touched, hook]
uses_types: []
file_path: "frontend/types/core/form_state.ts"
---
## Campos
- `values` — valores actuales del formulario, tipados con el generico T
- `errors` — errores de validacion por campo (string con mensaje), undefined si el campo es valido
- `touched` — true por campo cuando el usuario lo ha modificado (util para mostrar errores solo al tocar)
- `is_valid` — true cuando no hay ningun error de validacion en ningun campo
- `is_submitting` — true mientras el handler de submit esta en ejecucion
## Uso
```typescript
import type { FormState } from '@/types/core/form_state'
interface LoginForm { email: string; password: string }
function LoginPage() {
const form: FormState<LoginForm> = useForm({ initial_values: { email: '', password: '' } })
}
```
## Notas
Retornado por `use_form_ts_core`. El generico `T extends Record<string, unknown>` permite tipado completo de los campos y sus errores. Para formularios complejos con validacion async o arrays de campos, usar `@mantine/form` directamente.
+13
View File
@@ -0,0 +1,13 @@
/** Estado de un formulario con validacion sincrona. */
export interface FormState<T extends Record<string, unknown>> {
/** Valores actuales del formulario */
values: T
/** Errores por campo (undefined si el campo no tiene error) */
errors: Partial<Record<keyof T, string>>
/** Campos que el usuario ha modificado al menos una vez */
touched: Partial<Record<keyof T, boolean>>
/** Si todos los campos pasan la validacion */
is_valid: boolean
/** Si el submit esta en progreso */
is_submitting: boolean
}
+43
View File
@@ -0,0 +1,43 @@
---
name: MutationState
lang: ts
domain: core
version: "1.0.0"
algebraic: product
definition: |
interface MutationState<TData, TVariables> {
mutate: (variables: TVariables) => void
mutate_async: (variables: TVariables) => Promise<TData>
data: TData | null
error: Error | null
loading: boolean
reset: () => void
}
description: "Estado de una mutacion POST/PUT/DELETE. Expone mutate, mutate_async, data, error, loading y reset."
tags: [mutation, state, http, post, put, delete, hook]
uses_types: []
file_path: "frontend/types/core/mutation_state.ts"
---
## Campos
- `mutate` — ejecutar la mutacion de forma fire-and-forget (no lanza excepciones)
- `mutate_async` — ejecutar la mutacion y obtener una Promise (puede rechazar)
- `data` — resultado de la ultima mutacion exitosa, null si aun no hubo exito
- `error` — error de la ultima mutacion fallida, null si fue exitosa o no se ha ejecutado
- `loading` — true mientras la mutacion esta en vuelo
- `reset` — volver al estado inicial (idle, data null, error null)
## Uso
```typescript
import type { MutationState } from '@/types/core/mutation_state'
function useCreateUser(): MutationState<User, CreateUserInput> {
// ...
}
```
## Notas
Retornado por `use_mutation_ts_core`. El patron `mutate` / `mutate_async` permite usar el hook tanto en event handlers (sin async/await) como en funciones que necesitan esperar el resultado.
+15
View File
@@ -0,0 +1,15 @@
/** Estado de una mutacion POST/PUT/DELETE. */
export interface MutationState<TData, TVariables> {
/** Ejecutar la mutacion (fire-and-forget) */
mutate: (variables: TVariables) => void
/** Ejecutar la mutacion y esperar el resultado */
mutate_async: (variables: TVariables) => Promise<TData>
/** Datos del resultado de la ultima mutacion exitosa */
data: TData | null
/** Error si la ultima mutacion fallo */
error: Error | null
/** Si hay una mutacion en progreso */
loading: boolean
/** Resetear al estado inicial (idle) */
reset: () => void
}
BIN
View File
Binary file not shown.