fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
427 lines
19 KiB
Markdown
427 lines
19 KiB
Markdown
---
|
|
id: "0017"
|
|
title: "Frontend Data Hooks"
|
|
status: completado
|
|
type: feature
|
|
domain:
|
|
- frontend
|
|
scope: multi-app
|
|
priority: alta
|
|
depends: []
|
|
blocks: []
|
|
related: []
|
|
created: 2026-05-17
|
|
updated: 2026-05-17
|
|
tags: []
|
|
---
|
|
# 0017 — Frontend Data Hooks
|
|
|
|
## Metadata
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **ID** | 0017 |
|
|
| **Estado** | pendiente |
|
|
| **Prioridad** | alta |
|
|
| **Tipo** | feature |
|
|
|
|
## Dependencias
|
|
|
|
Ninguna.
|
|
|
|
---
|
|
|
|
## Objetivo
|
|
|
|
Crear hooks React genericos para comunicacion con APIs HTTP (REST, SSE, WebSocket) que sirvan como base para cualquier frontend web. Actualmente solo existen hooks Wails (`use_wails_query`, `use_wails_mutation`, `use_wails_event`, `use_wails_stream`) que dependen de IPC Wails — no hay nada para apps web estandar que usan `fetch` contra un servidor HTTP.
|
|
|
|
## Contexto
|
|
|
|
- Los hooks Wails (`frontend/functions/ui/`) implementan un patron solido: cache, stale-while-revalidate, retry, optimistic updates. Pero estan atados a `window.runtime` de Wails.
|
|
- El registry tiene 70+ componentes Mantine en `frontend/functions/ui/` pero cero infraestructura de data-fetching para web.
|
|
- Cualquier app web nueva (dashboards, admin panels, SPAs) necesita reinventar el fetching desde cero o tirar de react-query/swr como dependencia externa.
|
|
- Con estos hooks, una app web solo importa `use_fetch`, `use_mutation`, etc. del registry y tiene todo resuelto. Misma filosofia que los hooks Wails pero sobre HTTP.
|
|
- El issue 0009 (HTTP Server) crea las primitivas del servidor. Este issue crea las primitivas del cliente que las consume.
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
frontend/functions/core/
|
|
├── api_client.ts — NEW: fetch wrapper configurable
|
|
├── api_client.md — NEW
|
|
├── use_fetch.tsx — NEW: hook GET con cache + SWR
|
|
├── use_fetch.md — NEW
|
|
├── use_mutation.tsx — NEW: hook POST/PUT/DELETE
|
|
├── use_mutation.md — NEW
|
|
├── use_infinite_scroll.tsx — NEW: hook paginacion infinita
|
|
├── use_infinite_scroll.md — NEW
|
|
├── use_form.tsx — NEW: hook formularios
|
|
├── use_form.md — NEW
|
|
├── use_debounced_search.tsx — NEW: hook busqueda con debounce
|
|
├── use_debounced_search.md — NEW
|
|
├── use_sse.tsx — NEW: hook Server-Sent Events
|
|
├── use_sse.md — NEW
|
|
├── use_websocket.tsx — NEW: hook WebSocket
|
|
├── use_websocket.md — NEW
|
|
|
|
frontend/types/core/
|
|
├── fetch_state.ts — NEW: tipo FetchState
|
|
├── fetch_state.md — NEW
|
|
├── mutation_state.ts — NEW: tipo MutationState
|
|
├── mutation_state.md — NEW
|
|
├── form_state.ts — NEW: tipo FormState
|
|
├── form_state.md — NEW
|
|
├── api_client_config.ts — NEW: tipo APIClientConfig
|
|
├── api_client_config.md — NEW
|
|
```
|
|
|
|
Todas las funciones van en `frontend/functions/core/` (dominio `core`) porque son TypeScript puro, framework-agnostic en el sentido de que no dependen de Wails, Electron, Tauri, ni ningun runtime especifico. Solo usan React + `fetch` nativo del browser.
|
|
|
|
### Patron pure core / impure shell
|
|
|
|
No hay funciones puras en este issue — todos los hooks manejan estado (React) y/o I/O (HTTP, SSE, WebSocket). `api_client` es una funcion impura (hace `fetch`), no un hook.
|
|
|
|
## Diseno
|
|
|
|
### Tipos
|
|
|
|
```typescript
|
|
/** Estado de un fetch GET */
|
|
interface FetchState<T> {
|
|
data: T | null
|
|
error: Error | null
|
|
loading: boolean
|
|
/** Re-ejecutar la peticion */
|
|
refetch: () => Promise<T>
|
|
/** Si los datos son stale (cache viejo mostrando mientras refetch) */
|
|
is_stale: boolean
|
|
}
|
|
|
|
/** Estado de una mutacion POST/PUT/DELETE */
|
|
interface MutationState<TData, TVariables> {
|
|
/** Ejecutar la mutacion */
|
|
mutate: (variables: TVariables) => void
|
|
/** Ejecutar la mutacion (async, retorna Promise) */
|
|
mutate_async: (variables: TVariables) => Promise<TData>
|
|
data: TData | null
|
|
error: Error | null
|
|
loading: boolean
|
|
/** Resetear al estado inicial */
|
|
reset: () => void
|
|
}
|
|
|
|
/** Estado de un formulario */
|
|
interface FormState<T extends Record<string, unknown>> {
|
|
/** Valores actuales del form */
|
|
values: T
|
|
/** Errores por campo */
|
|
errors: Partial<Record<keyof T, string>>
|
|
/** Campos que el usuario ha tocado */
|
|
touched: Partial<Record<keyof T, boolean>>
|
|
/** Si todos los campos pasan validacion */
|
|
is_valid: boolean
|
|
/** Si el submit esta en progreso */
|
|
is_submitting: boolean
|
|
}
|
|
|
|
/** Configuracion del cliente HTTP */
|
|
interface APIClientConfig {
|
|
/** URL base para todas las peticiones (ej: "http://localhost:8484/api") */
|
|
base_url: string
|
|
/** Headers fijos para todas las peticiones */
|
|
headers?: Record<string, string>
|
|
/** Callback global de error */
|
|
on_error?: (error: Error, url: string, init?: RequestInit) => void
|
|
/** Callback cuando el server responde 401 */
|
|
on_unauthorized?: () => void
|
|
}
|
|
```
|
|
|
|
### Funciones
|
|
|
|
| Funcion | Kind | Purity | Firma (simplificada) |
|
|
|---------|------|--------|---------------------|
|
|
| `api_client` | function | impure | `(config: APIClientConfig) => { get, post, put, del, patch }` |
|
|
| `use_fetch` | component | impure | `useFetch<T>(url, opts?): FetchState<T>` |
|
|
| `use_mutation` | component | impure | `useMutation<TData, TVars>(opts): MutationState<TData, TVars>` |
|
|
| `use_infinite_scroll` | component | impure | `useInfiniteScroll<T>(url, opts?): { data, loading, has_more, load_more, reset }` |
|
|
| `use_form` | component | impure | `useForm<T>(opts): { values, errors, touched, set_field, validate, submit, reset }` |
|
|
| `use_debounced_search` | component | impure | `useDebouncedSearch<T>(url, opts?): { query, set_query, results, loading }` |
|
|
| `use_sse` | component | impure | `useSSE<T>(url, opts?): { data, last_event, status, close }` |
|
|
| `use_websocket` | component | impure | `useWebSocket<TSend, TRecv>(url, opts?): { send, last_message, status, close }` |
|
|
|
|
### Detalle de cada funcion
|
|
|
|
**`api_client`** — Factory que retorna un objeto con metodos HTTP. No es un hook, es una funcion normal que crea un cliente reutilizable. Cada metodo hace `fetch` con los headers y base_url configurados, parsea JSON automaticamente, y llama a `on_error`/`on_unauthorized` segun el status code.
|
|
|
|
```typescript
|
|
const api = apiClient({ base_url: 'http://localhost:8484/api' })
|
|
const users = await api.get<User[]>('/users')
|
|
await api.post('/users', { name: 'Lucas' })
|
|
await api.del('/users/123')
|
|
```
|
|
|
|
**`use_fetch`** — Hook para GET requests. Hace fetch al montar (configurable con `enabled`). Cache en memoria con stale-while-revalidate: muestra datos viejos inmediatamente y refetchea en background. Soporta `refetch_interval`, `refetch_on_focus`, `stale_time`, `retry`. Recibe una URL string o una funcion que retorna `Promise<T>` (para usar con `api_client`).
|
|
|
|
**`use_mutation`** — Hook para operaciones de escritura. No ejecuta al montar — expone `mutate()` y `mutate_async()`. Soporta `on_mutate` para optimistic updates (retorna rollback context), `on_success`, `on_error`, `on_settled`. Opcionalmente invalida queries del cache al completar.
|
|
|
|
**`use_infinite_scroll`** — Hook para paginacion infinita. Soporta cursor-based (`next_cursor` en response) y offset-based (`page` incrementando). Expone `load_more()`, `has_more`, `reset()`. Concatena paginas en un solo array plano.
|
|
|
|
**`use_form`** — Hook para formularios. Recibe `initial_values` y opcionalmente `validate` (funcion que retorna errores por campo). Expone `set_field(name, value)`, `set_fields(partial)`, `validate()`, `submit(handler)`, `reset()`. El submit llama a `validate()` primero y solo ejecuta el handler si pasa. Marca campos como `touched` al hacer set.
|
|
|
|
**`use_debounced_search`** — Hook que combina un input debounced con fetch. Recibe la URL base y el debounce delay (default 300ms). Mientras el usuario escribe, espera N ms sin actividad y luego hace GET con el query como parametro. Expone `query`, `set_query`, `results`, `loading`.
|
|
|
|
**`use_sse`** — Hook para Server-Sent Events. Crea un `EventSource` al montar, parsea cada evento como JSON, auto-reconecta con backoff exponencial si se pierde la conexion. Expone `data` (array de eventos), `last_event`, `status` (connecting/open/closed), `close()`.
|
|
|
|
**`use_websocket`** — Hook para WebSocket. Conecta al montar, auto-reconecta con backoff. Expone `send(data)`, `last_message`, `messages` (buffer), `status` (connecting/open/closing/closed), `close()`. Soporta tipado separado para envio (`TSend`) y recepcion (`TRecv`).
|
|
|
|
## Tareas
|
|
|
|
### Fase 1: Tipos y api_client
|
|
|
|
- [ ] **1.1** Crear tipo `FetchState` en `frontend/types/core/fetch_state.ts` + `.md`
|
|
- [ ] **1.2** Crear tipo `MutationState` en `frontend/types/core/mutation_state.ts` + `.md`
|
|
- [ ] **1.3** Crear tipo `FormState` en `frontend/types/core/form_state.ts` + `.md`
|
|
- [ ] **1.4** Crear tipo `APIClientConfig` en `frontend/types/core/api_client_config.ts` + `.md`
|
|
- [ ] **1.5** Crear `api_client` en `frontend/functions/core/api_client.ts` + `.md` — fetch wrapper con base_url, headers, error handling, interceptors
|
|
|
|
### Fase 2: Hooks de datos (core HTTP)
|
|
|
|
- [ ] **2.1** `use_fetch` — GET con cache in-memory, stale-while-revalidate, retry, refetch por intervalo/foco
|
|
- [ ] **2.2** `use_mutation` — POST/PUT/DELETE con optimistic updates, invalidacion de queries
|
|
- [ ] **2.3** `use_debounced_search` — input con debounce + fetch automatico
|
|
- [ ] **2.4** `use_form` — state de formulario con validacion, touched, submit handler
|
|
|
|
### Fase 3: Hooks de streaming y paginacion
|
|
|
|
- [ ] **3.1** `use_infinite_scroll` — paginacion infinita (cursor y offset), concatenacion de paginas
|
|
- [ ] **3.2** `use_sse` — Server-Sent Events con auto-reconnect y parsing JSON
|
|
- [ ] **3.3** `use_websocket` — WebSocket con auto-reconnect, send tipado, buffer de mensajes
|
|
|
|
### Fase 4: Integracion y verificacion
|
|
|
|
- [ ] **4.1** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db
|
|
- [ ] **4.2** Verificar que los IDs siguen el patron `{name}_ts_core`
|
|
- [ ] **4.3** Verificar imports cruzados: los hooks importan los tipos de `frontend/types/core/`
|
|
- [ ] **4.4** Verificar que `api_client` se puede pasar como `fetch_fn` a `use_fetch` y `use_mutation`
|
|
|
|
---
|
|
|
|
## Ejemplo de uso
|
|
|
|
Pagina CRUD completa usando los hooks con el layout `crud_page_ts_ui` existente:
|
|
|
|
```tsx
|
|
import { apiClient } from '@fn_library/core/api_client'
|
|
import { useFetch } from '@fn_library/core/use_fetch'
|
|
import { useMutation } from '@fn_library/core/use_mutation'
|
|
import { useForm } from '@fn_library/core/use_form'
|
|
import { useDebouncedSearch } from '@fn_library/core/use_debounced_search'
|
|
import { crudPage } from '@fn_library/ui/crud_page'
|
|
import { Stack, Modal, TextInput, Button, Group } from '@mantine/core'
|
|
import { useDisclosure } from '@mantine/hooks'
|
|
|
|
// 1. Configurar cliente HTTP una vez
|
|
const api = apiClient({
|
|
base_url: 'http://localhost:8484/api',
|
|
on_unauthorized: () => window.location.href = '/login',
|
|
})
|
|
|
|
interface User {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
role: string
|
|
}
|
|
|
|
function UsersPage() {
|
|
const [opened, { open, close }] = useDisclosure(false)
|
|
|
|
// 2. Fetch lista de usuarios con cache
|
|
const { data: users, loading, refetch } = useFetch<User[]>({
|
|
url: '/users',
|
|
fetch_fn: api.get,
|
|
stale_time: 10_000, // Fresh por 10s
|
|
refetch_on_focus: true, // Refetch al volver a la tab
|
|
})
|
|
|
|
// 3. Busqueda con debounce
|
|
const { query, set_query, results: searchResults, loading: searching } = useDebouncedSearch<User[]>({
|
|
url: '/users/search',
|
|
fetch_fn: api.get,
|
|
delay: 300,
|
|
param_name: 'q',
|
|
})
|
|
|
|
// 4. Mutacion para crear usuario
|
|
const { mutate: createUser, loading: creating } = useMutation<User, Partial<User>>({
|
|
mutation_fn: (data) => api.post('/users', data),
|
|
on_success: () => {
|
|
refetch()
|
|
close()
|
|
form.reset()
|
|
},
|
|
})
|
|
|
|
// 5. Mutacion para eliminar usuario
|
|
const { mutate: deleteUser } = useMutation<void, string>({
|
|
mutation_fn: (id) => api.del(`/users/${id}`),
|
|
on_success: () => refetch(),
|
|
})
|
|
|
|
// 6. Form con validacion
|
|
const form = useForm<Partial<User>>({
|
|
initial_values: { name: '', email: '', role: 'user' },
|
|
validate: (values) => ({
|
|
name: !values.name ? 'Nombre requerido' : undefined,
|
|
email: !values.email?.includes('@') ? 'Email invalido' : undefined,
|
|
}),
|
|
})
|
|
|
|
const displayedUsers = query ? searchResults : users
|
|
|
|
return (
|
|
<Stack>
|
|
{/* Barra de busqueda */}
|
|
<TextInput
|
|
placeholder="Buscar usuarios..."
|
|
value={query}
|
|
onChange={(e) => set_query(e.currentTarget.value)}
|
|
/>
|
|
|
|
{/* Tabla CRUD usando crud_page del registry */}
|
|
{crudPage<User>({
|
|
title: 'Users',
|
|
subtitle: `${displayedUsers?.length ?? 0} usuarios`,
|
|
data: displayedUsers ?? [],
|
|
fields: [
|
|
{ key: 'name', label: 'Nombre', type: 'text', required: true },
|
|
{ key: 'email', label: 'Email', type: 'email', required: true },
|
|
{ key: 'role', label: 'Rol', type: 'select', options: [
|
|
{ label: 'Admin', value: 'admin' },
|
|
{ label: 'User', value: 'user' },
|
|
]},
|
|
],
|
|
columns: [
|
|
{ key: 'name', label: 'Nombre' },
|
|
{ key: 'email', label: 'Email' },
|
|
{ key: 'role', label: 'Rol' },
|
|
],
|
|
onAdd: open,
|
|
onDelete: (user) => deleteUser(user.id),
|
|
})}
|
|
|
|
{/* Modal de creacion */}
|
|
<Modal opened={opened} onClose={close} title="Nuevo usuario">
|
|
<Stack>
|
|
<TextInput
|
|
label="Nombre"
|
|
value={form.values.name ?? ''}
|
|
error={form.errors.name}
|
|
onChange={(e) => form.set_field('name', e.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
label="Email"
|
|
value={form.values.email ?? ''}
|
|
error={form.errors.email}
|
|
onChange={(e) => form.set_field('email', e.currentTarget.value)}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={close}>Cancelar</Button>
|
|
<Button
|
|
loading={creating}
|
|
onClick={() => form.submit((values) => createUser(values))}
|
|
>
|
|
Crear
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Ejemplo con SSE (logs en tiempo real)
|
|
|
|
```tsx
|
|
import { useSSE } from '@fn_library/core/use_sse'
|
|
import { Paper, Code, ScrollArea, Badge, Group } from '@mantine/core'
|
|
|
|
function LogViewer() {
|
|
const { data: logs, last_event, status, close } = useSSE<string>({
|
|
url: 'http://localhost:8484/api/logs/stream',
|
|
reconnect: true,
|
|
reconnect_interval: 3000,
|
|
})
|
|
|
|
return (
|
|
<Paper withBorder p="md">
|
|
<Group mb="sm">
|
|
<Badge color={status === 'open' ? 'green' : 'red'}>{status}</Badge>
|
|
</Group>
|
|
<ScrollArea h={400}>
|
|
<Code block>{logs.join('\n')}</Code>
|
|
</ScrollArea>
|
|
</Paper>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Ejemplo con WebSocket (chat)
|
|
|
|
```tsx
|
|
import { useWebSocket } from '@fn_library/core/use_websocket'
|
|
import { TextInput, Paper, Stack, Text } from '@mantine/core'
|
|
import { useState } from 'react'
|
|
|
|
interface ChatMessage { user: string; text: string; ts: number }
|
|
|
|
function Chat() {
|
|
const [input, setInput] = useState('')
|
|
const { send, messages, status } = useWebSocket<{ text: string }, ChatMessage>({
|
|
url: 'ws://localhost:8484/ws/chat',
|
|
reconnect: true,
|
|
})
|
|
|
|
return (
|
|
<Stack>
|
|
<Paper withBorder p="md" h={300} style={{ overflow: 'auto' }}>
|
|
{messages.map((msg, i) => (
|
|
<Text key={i} size="sm"><b>{msg.user}:</b> {msg.text}</Text>
|
|
))}
|
|
</Paper>
|
|
<TextInput
|
|
placeholder="Escribir mensaje..."
|
|
value={input}
|
|
disabled={status !== 'open'}
|
|
onChange={(e) => setInput(e.currentTarget.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && input.trim()) {
|
|
send({ text: input })
|
|
setInput('')
|
|
}
|
|
}}
|
|
/>
|
|
</Stack>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Decisiones de diseno
|
|
|
|
- **Zero dependencias externas:** No se usa react-query, swr, axios, ni ninguna libreria de fetching. Los hooks implementan un patron SWR-like minimalista con `fetch` nativo y `useState`/`useEffect`/`useRef`/`useCallback` de React. Esto mantiene el bundle pequeno y elimina dependencias que evolucionan independientemente.
|
|
- **Cache in-memory simple (Map):** Los hooks Wails ya tienen un cache propio (`wails_cache.ts`). Los nuevos hooks usan un `Map<string, { data, timestamp }>` simple compartido via un modulo singleton. No se necesita un cache manager complejo — el patron stale-while-revalidate con un Map cubre el 90% de los casos.
|
|
- **`api_client` es funcion, no hook:** El cliente HTTP es una factory pura (en el sentido de React) que retorna metodos. Se puede usar fuera de componentes: en event handlers, en funciones de utilidad, en tests. Los hooks lo consumen opcionalmente via `fetch_fn`.
|
|
- **Dominio `core`, no `ui`:** Los hooks Wails viven en `ui` porque dependen de `wails_provider` y el runtime Wails (infraestructura de UI especifica). Estos hooks solo dependen de React + fetch nativo, asi que van en `core` — son TypeScript puro reutilizable en cualquier proyecto React, no atados a un framework de UI.
|
|
- **`kind: component` para hooks:** Siguiendo el mismo patron que los hooks Wails en el registry. Un hook React es un componente funcional que tiene estado (`has_state: true`), emite callbacks (`emits`), y declara props. El `kind: component` con `framework: react` es el mapping correcto.
|
|
- **Tipado generico pero no over-engineered:** Cada hook acepta 1-2 genericos (`T` para data, `TVariables` para inputs). No se introducen abstractions como query keys jerarquicos, cache normalization, ni infinite query cursors tipados. Si se necesita TanStack Query, se instala — estos hooks cubren el 80% sin la complejidad.
|
|
- **`use_form` minimalista:** No reemplaza a Mantine `useForm` ni a formik/react-hook-form. Es un hook ligero para formularios simples: valores, errores, touched, validacion sincrona, submit. Para formularios complejos (arrays de campos, validacion async, schemas zod) se usa la solucion de Mantine directamente.
|
|
|
|
## Riesgos
|
|
|
|
- **Duplicacion con hooks Wails:** Los hooks de datos HTTP y los hooks Wails resuelven el mismo problema con diferente transporte. Mitigado manteniendo interfaces similares (`FetchState` vs `QueryState`, `MutationState` en ambos) para que migrar de uno a otro sea trivial. A futuro se podria abstraer el transporte detras de una interfaz comun, pero por ahora es premature abstraction.
|
|
- **Cache sin persistencia ni invalidacion cross-tab:** El cache es un `Map` en memoria. Si el usuario abre dos tabs, cada una tiene su cache independiente. Mitigado porque para el 90% de las apps del registry (tools internas, dashboards, admin panels) esto es aceptable. Si se necesita cache persistente o broadcast, se anade como iteracion futura.
|
|
- **Scope creep hacia framework de fetching:** Estos hooks deben mantenerse simples. Si el uso crece hasta necesitar normalized cache, query deduplication, garbage collection, o suspense — en ese punto se migra a TanStack Query. El valor de estos hooks es zero-dep y cubrir los casos comunes.
|
|
- **`use_form` puede solaparse con Mantine `useForm`:** Documentar claramente que `use_form` es para formularios simples sin dependencia de Mantine. Si la app ya usa Mantine (que es el caso comun en este registry), `@mantine/form` puede ser mejor opcion. El hook del registry es util cuando se quiere evitar la dependencia de Mantine en un proyecto core.
|