JavaScript / React — Experiencia Técnica

StackLibrerías utilizadas

Problemas resueltos

ProblemaTécnica
API Proxy a dispositivos IoTFrontend → cloud-side → MQTT → gateway → dispositivo
Countdown de pairing con cleanupuseEffect + useRef para interval, auto-cancelación en 0
Logout automático global en 401/403setLoggedIn como parámetro en todas las funciones API
Cancelación de request HTTPAbortController en useRef, AbortError no tratado como error
Invalidación selectiva de cacheQuery key hierarchy — invalidar solo lo que cambió

Lenguaje y ecosystem

TemaNivel
TanStack React QueryuseQuery, useMutation, invalidación selectiva por key hierarchy
useEffect + cleanupTimers con cleanup garantizado, sync con server state
useContextAuth global con LoginContext + logout automático en 401/403
useStateEstado local: modales, formularios, selecciones, flags
useRefAbortController, DOM inputs no controlados, timer IDs
useMemoMemoización del tema MUI
Fetch API + async/awaitHTTP sin axios: authHeaders, parseResponse, AbortController
Dispatch por tipo de dispositivoswitch sobre device.type → componente de control específico
React Router v7BrowserRouter, Navigate condicional
MUI — temas y stylingCSS variables, dark mode, styled(), sx prop
Validación de formulariostry-catch JSON, regex email, botón deshabilitado
Variables de entorno Viteimport.meta.env, prefijo VITE_

Patrones de diseño

PatrónImplementación
Three-layer state managementServer state (React Query) / global (Context) / local (useState)
Panel Router con estado elevadoSummaryPage como router de panels con estado selectedDetail
Controlados vs no controladosTextField controlado para forms complejos, ref para submit simple
Theme customization por componentestyleOverrides por componente MUI mergeado con el tema

Ver Arquitectura del sistema para diagramas de capas y flujo de comandos.


Librerías Utilizadas

Core

LibreríaVersiónUso
ReactlatestUI rendering, hooks, context
React DOMlatestDOM rendering
VitelatestBuild tool y dev server (HMR, ESM nativo)
@vitejs/plugin-reactlatestSoporte JSX + Fast Refresh en Vite

UI / Componentes

LibreríaVersiónUso
@mui/material^7.3.4Componentes Material Design completos
@mui/icons-material^7.3.4Iconografía Material
@emotion/react^11.14.0CSS-in-JS engine (dependencia de MUI)
@emotion/styled^11.14.1styled() wrapper para componentes custom
@mui/x-data-grid^8.15.0Tabla avanzada (gateways, tunnels) con sort/filter
@mui/x-charts^8.15.0Gráficos de telemetría
@mui/x-date-pickers^8.15.0Selectores de fecha/hora

Routing

LibreríaVersiónUso
react-router-dom^7.9.4BrowserRouter, Routes, Route, Navigate

Data Fetching / Estado del servidor

LibreríaVersiónUso
@tanstack/react-query^5.90.6Cache de server state, useQuery, useMutation, invalidación

Utilidades

LibreríaVersiónUso
dayjs^1.11.18Manipulación de fechas (liviano vs Moment.js)

Cobertura del Lenguaje y Ecosystem

Hooks de React

useState — Estado local

const [selectedGateway, setSelectedGateway] = useState(null);
const [selectedDetail, setSelectedDetail] = useState(null);  // null|'tunnels'|'proxy'|'devices'

const [form, setForm] = useState({ name: '', steps: '[]' });

{selectedDetail === 'tunnels' && <TunnelList gwId={selectedGateway} />}
{selectedDetail === 'proxy'   && <ProxyPanel gwId={selectedGateway} />}
{selectedDetail === 'devices' && <DevicesPage gwId={selectedGateway} />}

useEffect — Side effects y cleanup

// PairingPanel.jsx — countdown con cleanup
useEffect(() => {
  if (!active) return;

  timerRef.current = setInterval(() => {
    setSecondsLeft(s => {
      if (s <= 1) {
        clearInterval(timerRef.current);
        stopMutation.mutate();
        return 0;
      }
      return s - 1;
    });
  }, 1000);

  return () => clearInterval(timerRef.current);  // cleanup al desmontar o cambiar [active]
}, [active]);

// ThermostatControl — sincronizar estado local con dato del servidor
useEffect(() => {
  if (data) {
    setMode(data.mode);
    setHeat(data.heat);
    setCool(data.cool);
  }
}, [data]);

useRef — Sin re-render

// ProxyPanel.jsx — AbortController para cancelar requests
const abortControllerRef = useRef(null);

const handleSend = async () => {
  abortControllerRef.current = new AbortController();
  const response = await fetch(url, {
    signal: abortControllerRef.current.signal
  });
};

const handleCancel = () => abortControllerRef.current?.abort();

// Captura del AbortError (no mostrar como error de red)
try {
  const response = await fetch(url, { signal });
} catch (err) {
  if (err.name === 'AbortError') return;
  setError(err.message);
}

// PairingPanel.jsx — ref para interval (evita re-render)
const timerRef = useRef(null);

// SignIn.jsx — acceso directo a inputs (sin estado controlado)
const emailRef = useRef(null);
const passwordRef = useRef(null);

useContext — Estado global de auth

// App.jsx — definición del contexto
export const LoginContext = createContext(null);

function App() {
  const [loggedIn, setLoggedIn] = useState(!!localStorage.getItem('access'));
  return (
    <LoginContext.Provider value={[loggedIn, setLoggedIn]}>
      <Router>...</Router>
    </LoginContext.Provider>
  );
}

const [loggedIn, setLoggedIn] = useContext(LoginContext);

// api.jsx — logout automático en 401/403
function handleResponse(response, setLoggedIn) {
  if (response.status === 401 || response.status === 403) {
    localStorage.removeItem('access');
    setLoggedIn(false);
  }
}

useMemo — Memoización del tema

// AppTheme.jsx — evita recrear el tema en cada render
const theme = React.useMemo(() =>
  createTheme({
    cssVariables: { colorSchemeSelector: 'data-mui-color-scheme' },
    colorSchemes: { light: true, dark: true },
    ...themeComponents,
  }),
  [disableCustomTheme, themeComponents]
);

TanStack React Query

El mecanismo central para datos del servidor. Reemplaza fetch manual + useState + useEffect.

useQuery — Datos de solo lectura

// DevicesPage.jsx — fetch con condición
const { isPending, error, data } = useQuery({
  queryKey: ['gatewaySummary', gwId],          // clave única para cache
  queryFn:  () => fetchGatewaySummary(gwId, setLoggedIn),
  enabled:  !!gwId,                             // no ejecutar si no hay gwId
});

if (isPending) return <CircularProgress />;
if (error)    return <Alert severity="error">{error.message}</Alert>;

useMutation — Operaciones de escritura

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: (value) => devicePost(gwId, device.id, 'lock', { value }, setLoggedIn),
  onSuccess:  () => queryClient.invalidateQueries({ queryKey: qKey }),
});

<Button
  onClick={() => mutation.mutate('lock')}
  disabled={mutation.isPending}
  loading={mutation.isPending}
>
  Lock
</Button>
{mutation.error && <Alert severity="error">{mutation.error.message}</Alert>}

Estructura de Query Keys

['userData']                              → summary del usuario (gateways, estados)
['gatewaySummary', gwId]                  → dispositivos de un gateway
['device', gwId, deviceId, 'lock']        → estado de un dispositivo específico
['device', gwId, deviceId, 'thermostat']
['pincodes', gwId, deviceId]              → códigos PIN de una cerradura
['sequences', gwId]                       → secuencias de automatización
['tunnels', gwId]                         → tunnels SSH de un gateway

La granularidad permite invalidar solo lo que cambió:

// Después de renombrar un device, solo invalidar ese gateway
queryClient.invalidateQueries({ queryKey: ['gatewaySummary', gwId] });

// Después de crear un tunnel, invalidar summary Y tunnels
queryClient.invalidateQueries({ queryKey: ['userData'] });
queryClient.invalidateQueries({ queryKey: ['tunnels', gwId] });

Fetch API y Async/Await

// api.jsx — patrón base de todas las funciones API
export async function fetchUserSummary(setLoggedIn) {
  const response = await fetch(baseUrl + 'api/v1/summary', {
    headers: authHeaders()  // { Authorization: 'Bearer <token>' }
  });
  handleResponse(response, setLoggedIn);  // logout si 401/403
  return parseResponse(response);         // null si 204, JSON si 200
}

// Headers de auth desde localStorage
function authHeaders() {
  return { Authorization: `Bearer ${localStorage.getItem('access')}` };
}

// Manejo de 204 No Content
async function parseResponse(response) {
  if (response.status === 204) return null;
  return response.json();
}

// Proxy transparente hacia el gateway
export async function proxyRequest(gwId, method, path, body, setLoggedIn) {
  const response = await fetch(
    `${baseUrl}api/v1/${gwId}/proxy/${path}`,
    {
      method,
      headers: { ...authHeaders(), 'Content-Type': 'application/json' },
      body: body ? JSON.stringify(body) : undefined,
    }
  );
  handleResponse(response, setLoggedIn);
  return parseResponse(response);
}

React Router v7

// App.jsx — estructura de rutas
<BrowserRouter>
  <Routes>
    <Route path="/"          element={<Navigate to="/dashboard" replace />} />
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/login"     element={<SignIn />} />
  </Routes>
</BrowserRouter>

// Dashboard.jsx — redirect si no está autenticado
const [loggedIn] = useContext(LoginContext);
if (!loggedIn) return <Navigate to="/login" replace />;

Patrón de Dispatch por Tipo de Dispositivo

// DeviceDetail.jsx — dispatcher de controles
function Controls({ gwId, device, setLoggedIn }) {
  switch (device.type) {
    case 'lock':       return <LockControl       gwId={gwId} device={device} setLoggedIn={setLoggedIn} />;
    case 'switch':     return <SwitchControl     gwId={gwId} device={device} setLoggedIn={setLoggedIn} />;
    case 'thermostat': return <ThermostatControl gwId={gwId} device={device} setLoggedIn={setLoggedIn} />;
    case 'sensor':     return <SensorControl     gwId={gwId} device={device} setLoggedIn={setLoggedIn} />;
    default:           return <Typography>Sin controles para este tipo</Typography>;
  }
}

// DeviceCard.jsx — acciones rápidas inline
function QuickActions({ gwId, device, setLoggedIn }) {
  switch (device.type) {
    case 'lock':   return <QuickLockToggle   ... />;
    case 'switch': return <QuickSwitchToggle ... />;
    default:       return null;
  }
}

MUI — Sistema de Temas y Styling

// AppTheme.jsx — CSS variables + dark mode automático
const theme = useMemo(() => createTheme({
  cssVariables: {
    colorSchemeSelector: 'data-mui-color-scheme',
  },
  colorSchemes: { light: true, dark: true },
  ...themeComponents,
}), [disableCustomTheme, themeComponents]);

const { mode, setMode } = useColorScheme();
<IconButton onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}>
  {mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>

const FormGrid = styled(Grid)(({ theme }) => ({
  display: 'flex',
  flexDirection: 'column',
  gap: theme.spacing(2),
}));

<Box sx={{
  display: 'flex',
  gap: 2,
  color: 'text.secondary',
  bgcolor: { xs: 'background.paper', md: 'transparent' },
  p: { xs: 1, md: 2 },
}} />

Validación de Formularios

// SequencesPanel.jsx — validación de JSON con try-catch
const isValidJson = (str) => {
  try { JSON.parse(str); return true; }
  catch { return false; }
};

<TextField
  label="Steps (JSON)"
  value={form.steps}
  onChange={e => setForm({ ...form, steps: e.target.value })}
  error={form.steps.length > 0 && !isValidJson(form.steps)}
  helperText={!isValidJson(form.steps) ? 'JSON inválido' : ''}
  multiline rows={4}
/>

<Button disabled={!form.name.trim() || !isValidJson(form.steps)}>
  Guardar
</Button>

// SignIn.jsx — validación de email con regex
const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

Configuración por Entorno (Vite)

// Shared.jsx — URL base desde variable de entorno
export const baseUrl = import.meta.env.VITE_API_BASE_URL;

// .env.local (no commitear)
VITE_API_BASE_URL=https://api.example.com/

// Vite expone solo las variables con prefijo VITE_
// import.meta.env en lugar de process.env (ESM nativo)

Problemas Técnicos Resueltos

1. API Proxy Pattern (Cloud → Gateway)

Los dispositivos IoT están en la red local del usuario; el frontend no puede hablar con ellos directamente. El backend actúa como proxy transparente:

Frontend → POST /api/v1/{gwId}/proxy/devices → cloud-side → MQTT → gateway-side → dispositivo
// gatewayApi.jsx — abstracción del proxy
export const deviceGet    = (gw, id, ep, setLI) => proxyRequest(gw, 'GET',    `${id}/${ep}`, null, setLI);
export const devicePost   = (gw, id, ep, body, setLI) => proxyRequest(gw, 'POST', `${id}/${ep}`, body, setLI);
export const deviceDelete = (gw, id, ep, setLI) => proxyRequest(gw, 'DELETE', `${id}/${ep}`, null, setLI);

// Uso en componente — parece una API directa
const { data } = useQuery({
  queryKey: ['device', gwId, device.id, 'lock'],
  queryFn:  () => deviceGet(gwId, device.id, 'lock', setLoggedIn),
});

2. Countdown de Pairing con Cleanup Garantizado

El proceso de inclusión Z-Wave/Zigbee dura 60 segundos. El componente maneja arranque/parada del timer, auto-cancelación al llegar a 0, y cleanup si se desmonta antes:

useEffect(() => {
  if (!active) return;

  timerRef.current = setInterval(() => {
    setSecondsLeft(s => {
      if (s <= 1) {
        clearInterval(timerRef.current);
        stopMutation.mutate();
        setActive(false);
        return 0;
      }
      return s - 1;
    });
  }, 1000);

  return () => clearInterval(timerRef.current);  // cleanup garantizado
}, [active]);

<LinearProgress variant="determinate" value={(secondsLeft / 60) * 100} />

3. Logout Automático Global en 401/403

Si el token expira, cualquier request en cualquier parte de la app debe desloguear. Sin interceptores de axios, con fetch puro:

// api.jsx — helper llamado en cada request
function handleResponse(response, setLoggedIn) {
  if (response.status === 401 || response.status === 403) {
    localStorage.removeItem('access');
    setLoggedIn(false);  // dispara Navigate a /login via Context
  }
}

// Todas las funciones API reciben setLoggedIn como parámetro
export async function fetchGatewaySummary(gwId, setLoggedIn) {
  const response = await fetch(...);
  handleResponse(response, setLoggedIn);
  return parseResponse(response);
}

4. Cancelación de Request HTTP con AbortController

const abortControllerRef = useRef(null);

const handleSend = async () => {
  abortControllerRef.current = new AbortController();
  setLoading(true);
  try {
    const response = await fetch(url, {
      method, headers, body,
      signal: abortControllerRef.current.signal,
    });
    setResult(await response.json());
  } catch (err) {
    if (err.name === 'AbortError') return;  // usuario canceló, no es error
    setError(err.message);
  } finally {
    setLoading(false);
  }
};

const handleCancel = () => abortControllerRef.current?.abort();

5. Invalidación Selectiva de Cache

Después de una mutación, solo refetchear lo que cambió:

const createMutation = useMutation({
  mutationFn: (data) => saveTunnel(gwId, data, setLoggedIn),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['userData'] });
    queryClient.invalidateQueries({ queryKey: ['tunnels', gwId] });
  },
});

const renameMutation = useMutation({
  mutationFn: (name) => devicePost(gwId, device.id, 'name', { value: name }, setLoggedIn),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['gatewaySummary', gwId] }),
});

Patrones de Diseño Usados

Panel Router con Estado Elevado

SummaryPage actúa como router de panels. El estado selectedDetail determina qué panel renderizar:

const handleGWSelect = (gwId, detail) => {
  setSelectedGateway(gwId);
  setSelectedDetail(detail);  // 'devices' | 'tunnels' | 'proxy'
};

<GatewaysList onSelect={handleGWSelect} />

{selectedDetail === 'devices' && <DevicesPage gwId={selectedGateway} />}
{selectedDetail === 'tunnels' && <TunnelList  gwId={selectedGateway} />}
{selectedDetail === 'proxy'   && <ProxyPanel  gwId={selectedGateway} />}

State Management

Tres capas de estado con responsabilidades separadas:

CapaHerramientaQué maneja
Server stateTanStack React QueryDatos del backend (cache, loading, error, refetch)
Global client stateReact ContextAuth (token, loggedIn, setLoggedIn)
Local UI stateuseStateModales, formularios, selecciones, flags de edición

Componentes Controlados vs No Controlados

// Controlado — estado React es la fuente de verdad
<TextField
  value={editValue}
  onChange={e => setEditValue(e.target.value)}
/>

// No controlado con ref — para formularios de una sola acción (SignIn)
<TextField inputRef={emailRef} defaultValue="" />
// Lectura al submit: emailRef.current.value

Theme Customization por Componente

// dashboard/theme/customizations/dataGrid.jsx
export const dataGridCustomizations = {
  MuiDataGrid: {
    styleOverrides: {
      root: ({ theme }) => ({
        '--DataGrid-overlayHeight': '300px',
        borderColor: (theme.vars || theme).palette.divider,
      }),
    },
  },
};
// Se mergea con el tema principal en AppTheme