Files

19 KiB

id, title, status, type, domain, scope, priority, depends, blocks, related, created, updated, tags
id title status type domain scope priority depends blocks related created updated tags
0017 Frontend Data Hooks completado feature
frontend
multi-app alta
2026-05-17 2026-05-17

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

/** 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 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:

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 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.