19 KiB
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 awindow.runtimede 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
/** 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.
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
FetchStateenfrontend/types/core/fetch_state.ts+.md - 1.2 Crear tipo
MutationStateenfrontend/types/core/mutation_state.ts+.md - 1.3 Crear tipo
FormStateenfrontend/types/core/form_state.ts+.md - 1.4 Crear tipo
APIClientConfigenfrontend/types/core/api_client_config.ts+.md - 1.5 Crear
api_clientenfrontend/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 indexy 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_clientse puede pasar comofetch_fnause_fetchyuse_mutation
Ejemplo de uso
Pagina CRUD completa usando los hooks con el layout crud_page_ts_ui existente:
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)
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)
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
fetchnativo yuseState/useEffect/useRef/useCallbackde 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 unMap<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_clientes 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 viafetch_fn.- Dominio
core, noui: Los hooks Wails viven enuiporque dependen dewails_providery el runtime Wails (infraestructura de UI especifica). Estos hooks solo dependen de React + fetch nativo, asi que van encore— son TypeScript puro reutilizable en cualquier proyecto React, no atados a un framework de UI. kind: componentpara 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. Elkind: componentconframework: reactes el mapping correcto.- Tipado generico pero no over-engineered: Cada hook acepta 1-2 genericos (
Tpara data,TVariablespara 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_formminimalista: No reemplaza a MantineuseFormni 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 (
FetchStatevsQueryState,MutationStateen 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
Mapen 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_formpuede solaparse con MantineuseForm: Documentar claramente queuse_formes para formularios simples sin dependencia de Mantine. Si la app ya usa Mantine (que es el caso comun en este registry),@mantine/formpuede ser mejor opcion. El hook del registry es util cuando se quiere evitar la dependencia de Mantine en un proyecto core.