Stack — Librerías utilizadas
Problemas resueltos
| Problema | Técnica |
|---|---|
| API Proxy a dispositivos IoT | Frontend → cloud-side → MQTT → gateway → dispositivo |
| Countdown de pairing con cleanup | useEffect + useRef para interval, auto-cancelación en 0 |
| Logout automático global en 401/403 | setLoggedIn como parámetro en todas las funciones API |
| Cancelación de request HTTP | AbortController en useRef, AbortError no tratado como error |
| Invalidación selectiva de cache | Query key hierarchy — invalidar solo lo que cambió |
Lenguaje y ecosystem
| Tema | Nivel |
|---|---|
| TanStack React Query | useQuery, useMutation, invalidación selectiva por key hierarchy |
| useEffect + cleanup | Timers con cleanup garantizado, sync con server state |
| useContext | Auth global con LoginContext + logout automático en 401/403 |
| useState | Estado local: modales, formularios, selecciones, flags |
| useRef | AbortController, DOM inputs no controlados, timer IDs |
| useMemo | Memoización del tema MUI |
| Fetch API + async/await | HTTP sin axios: authHeaders, parseResponse, AbortController |
| Dispatch por tipo de dispositivo | switch sobre device.type → componente de control específico |
| React Router v7 | BrowserRouter, Navigate condicional |
| MUI — temas y styling | CSS variables, dark mode, styled(), sx prop |
| Validación de formularios | try-catch JSON, regex email, botón deshabilitado |
| Variables de entorno Vite | import.meta.env, prefijo VITE_ |
Patrones de diseño
| Patrón | Implementación |
|---|---|
| Three-layer state management | Server state (React Query) / global (Context) / local (useState) |
| Panel Router con estado elevado | SummaryPage como router de panels con estado selectedDetail |
| Controlados vs no controlados | TextField controlado para forms complejos, ref para submit simple |
| Theme customization por componente | styleOverrides por componente MUI mergeado con el tema |
Ver Arquitectura del sistema para diagramas de capas y flujo de comandos.
| Librería | Versión | Uso |
|---|---|---|
| React | latest | UI rendering, hooks, context |
| React DOM | latest | DOM rendering |
| Vite | latest | Build tool y dev server (HMR, ESM nativo) |
| @vitejs/plugin-react | latest | Soporte JSX + Fast Refresh en Vite |
| Librería | Versión | Uso |
|---|---|---|
| @mui/material | ^7.3.4 | Componentes Material Design completos |
| @mui/icons-material | ^7.3.4 | Iconografía Material |
| @emotion/react | ^11.14.0 | CSS-in-JS engine (dependencia de MUI) |
| @emotion/styled | ^11.14.1 | styled() wrapper para componentes custom |
| @mui/x-data-grid | ^8.15.0 | Tabla avanzada (gateways, tunnels) con sort/filter |
| @mui/x-charts | ^8.15.0 | Gráficos de telemetría |
| @mui/x-date-pickers | ^8.15.0 | Selectores de fecha/hora |
| Librería | Versión | Uso |
|---|---|---|
| react-router-dom | ^7.9.4 | BrowserRouter, Routes, Route, Navigate |
| Librería | Versión | Uso |
|---|---|---|
| @tanstack/react-query | ^5.90.6 | Cache de server state, useQuery, useMutation, invalidación |
| Librería | Versión | Uso |
|---|---|---|
| dayjs | ^1.11.18 | Manipulación de fechas (liviano vs Moment.js) |
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} />}
// 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]);
// 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);
// 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);
}
}
// 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]
);
El mecanismo central para datos del servidor. Reemplaza fetch manual + useState + useEffect.
// 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>;
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>}
['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] });
// 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);
}
// 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 />;
// 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;
}
}
// 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 },
}} />
// 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);
// 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)
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),
});
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} />
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);
}
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();
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] }),
});
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} />}
Tres capas de estado con responsabilidades separadas:
| Capa | Herramienta | Qué maneja |
|---|---|---|
| Server state | TanStack React Query | Datos del backend (cache, loading, error, refetch) |
| Global client state | React Context | Auth (token, loggedIn, setLoggedIn) |
| Local UI state | useState | Modales, formularios, selecciones, flags de edición |
// 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
// 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