Manual Completo de React consumiendo API FastAPI React + FastAPI

Implementación de un frontend en React para el sistema de inventario con autenticación JWT

🚀 Introducción

Este manual cubre el desarrollo completo de una aplicación React que consume la API FastAPI del manual anterior, implementando:

💡 Características Principales

📐 Arquitectura del Proyecto

inventory-frontend/
├── public/                  # Archivos estáticos
├── src/
│   ├── api/                 # Configuración de Axios y endpoints
│   │   ├── auth.js          # Endpoints de autenticación
│   │   └── products.js      # Endpoints de productos
│   ├── components/          # Componentes reutilizables
│   │   ├── Layout.js        # Layout principal
│   │   ├── Navbar.js        # Barra de navegación
│   │   └── PrivateRoute.js  # Ruta protegida
│   ├── context/             # Contextos de React
│   │   └── AuthContext.js   # Contexto de autenticación
│   ├── pages/               # Páginas/views
│   │   ├── auth/
│   │   │   ├── Login.js     # Página de login
│   │   │   └── Register.js  # Página de registro
│   │   ├── products/
│   │   │   ├── List.js      # Lista de productos
│   │   │   ├── Create.js    # Crear producto
│   │   │   └── Edit.js      # Editar producto
│   │   └── Home.js          # Página de inicio
│   ├── App.js               # Componente principal
│   ├── index.js             # Punto de entrada
│   └── styles/             # Estilos globales
├── package.json            # Dependencias
└── .env                    # Variables de entorno

1. Configuración del Proyecto

Crear la aplicación React

Terminal
# Crear aplicación React con Vite (recomendado)
npm create vite@latest inventory-frontend --template react
cd inventory-frontend

# Instalar dependencias básicas
npm install axios react-router-dom formik yup jwt-decode

# Opcional: Instalar Material-UI
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material

Configuración de variables de entorno

.env
VITE_API_BASE_URL=http://localhost:8000
VITE_API_TIMEOUT=5000

⚠️ Importante

En Vite, las variables de entorno para el cliente deben comenzar con VITE_ para ser accesibles en el código.

2. Configuración de Axios

Configuración base de Axios

src/api/axios.js
import axios from 'axios';

const api = axios.create({
    baseURL: import.meta.env.VITE_API_BASE_URL,
    timeout: import.meta.env.VITE_API_TIMEOUT,
});

// Interceptor para añadir el token JWT a las peticiones
api.interceptors.request.use((config) => {
    const token = localStorage.getItem('token');
    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
}, (error) => {
    return Promise.reject(error);
});

export default api;

Endpoints de Autenticación

src/api/auth.js
import api from './axios';

export const login = async (credentials) => {
    try {
        const response = await api.post('/token', 
            `username=${credentials.username}&password=${credentials.password}`,
            {
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                }
            }
        );
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const register = async (userData) => {
    try {
        const response = await api.post('/register', userData);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const getCurrentUser = () => {
    const token = localStorage.getItem('token');
    if (!token) return null;
    
    // Aquí podrías decodificar el token para obtener información del usuario
    // usando jwt-decode si lo necesitas
    return { username: 'Usuario' }; // Ejemplo simplificado
};

Endpoints de Productos

src/api/products.js
import api from './axios';

export const getProducts = async () => {
    try {
        const response = await api.get('/products');
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const getProduct = async (id) => {
    try {
        const response = await api.get(`/products/${id}`);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const createProduct = async (productData) => {
    try {
        const response = await api.post('/products', productData);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const updateProduct = async (id, productData) => {
    try {
        const response = await api.put(`/products/${id}`, productData);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const deleteProduct = async (id) => {
    try {
        const response = await api.delete(`/products/${id}`);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

3. Sistema de Autenticación

Contexto de Autenticación

src/context/AuthContext.js
import { createContext, useState, useEffect } from 'react';
import { login as apiLogin, register as apiRegister, getCurrentUser } from '../api/auth';

export const AuthContext = createContext();

export const AuthProvider = ({ children }) => {
    const [user, setUser] = useState(null);
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [loading, setLoading] = useState(true);

    // Verificar autenticación al cargar
    useEffect(() => {
        const checkAuth = async () => {
            try {
                const currentUser = getCurrentUser();
                if (currentUser) {
                    setUser(currentUser);
                    setIsAuthenticated(true);
                }
            } catch (error) {
                console.error('Error checking auth:', error);
            } finally {
                setLoading(false);
            }
        };
        checkAuth();
    }, []);

    const login = async (credentials) => {
        try {
            const data = await apiLogin(credentials);
            localStorage.setItem('token', data.access_token);
            const currentUser = getCurrentUser();
            setUser(currentUser);
            setIsAuthenticated(true);
            return true;
        } catch (error) {
            console.error('Login error:', error);
            return false;
        }
    };

    const register = async (userData) => {
        try {
            await apiRegister(userData);
            return true;
        } catch (error) {
            console.error('Register error:', error);
            return false;
        }
    };

    const logout = () => {
        localStorage.removeItem('token');
        setUser(null);
        setIsAuthenticated(false);
    };

    return (
        <AuthContext.Provider 
            value={{ 
                user, 
                isAuthenticated, 
                loading, 
                login, 
                register, 
                logout 
            }}
        >
            {children}
        </AuthContext.Provider>
    );
};

Componente de Ruta Privada

src/components/PrivateRoute.js
import { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';

const PrivateRoute = ({ children }) => {
    const { isAuthenticated, loading } = useContext(AuthContext);

    if (loading) {
        return <div>Loading...</div>; // O un spinner
    }

    return isAuthenticated ? children : <Navigate to="/login" />;
};

export default PrivateRoute;

Configuración de Rutas

src/App.js
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import Login from './pages/auth/Login';
import Register from './pages/auth/Register';
import Home from './pages/Home';
import ProductList from './pages/products/List';
import ProductCreate from './pages/products/Create';
import ProductEdit from './pages/products/Edit';
import Layout from './components/Layout';

function App() {
    return (
        <AuthProvider>
            <Router>
                <Layout>
                    <Routes>
                        <Route path="/login" element={<Login />} />
                        <Route path="/register" element={<Register />} />
                        <Route path="/" element={<Home />} />
                        
                        {/* Rutas protegidas */}
                        <Route path="/products" element={
                            <PrivateRoute>
                                <ProductList />
                            </PrivateRoute>
                        } />
                        <Route path="/products/create" element={
                            <PrivateRoute>
                                <ProductCreate />
                            </PrivateRoute>
                        } />
                        <Route path="/products/:id/edit" element={
                            <PrivateRoute>
                                <ProductEdit />
                            </PrivateRoute>
                        } />
                    </Routes>
                </Layout>
            </Router>
        </AuthProvider>
    );
}

export default App;

4. CRUD de Productos

Lista de Productos

src/pages/products/List.js
import { useState, useEffect, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { getProducts, deleteProduct } from '../../api/products';
import { AuthContext } from '../../context/AuthContext';

const ProductList = () => {
    const [products, setProducts] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    const { user } = useContext(AuthContext);
    const navigate = useNavigate();

    useEffect(() => {
        const fetchProducts = async () => {
            try {
                const data = await getProducts();
                setProducts(data);
            } catch (err) {
                setError(err.message || 'Error al cargar productos');
            } finally {
                setLoading(false);
            }
        };
        fetchProducts();
    }, []);

    const handleDelete = async (id) => {
        if (window.confirm('¿Estás seguro de eliminar este producto?')) {
            try {
                await deleteProduct(id);
                setProducts(products.filter(product => product.id !== id));
            } catch (err) {
                setError(err.message || 'Error al eliminar producto');
            }
        }
    };

    if (loading) return <div>Cargando productos...</div>;
    if (error) return <div>Error: {error}</div>;

    return (
        <div>
            <h1>Lista de Productos</h1>
            <button onClick={() => navigate('/products/create')}>
                Crear Nuevo Producto
            </button>
            
            <table>
                <thead>
                    <tr>
                        <th>Nombre</th>
                        <th>Precio</th>
                        <th>Stock</th>
                        <th>Acciones</th>
                    </tr>
                </thead>
                <tbody>
                    {products.map(product => (
                        <tr key={product.id}>
                            <td>{product.name}</td>
                            <td>${product.price}</td>
                            <td>{product.stock}</td>
                            <td>
                                <button onClick={() => navigate(`/products/${product.id}/edit`)}>
                                    Editar
                                </button>
                                <button onClick={() => handleDelete(product.id)}>
                                    Eliminar
                                </button>
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>
        </div>
    );
};

export default ProductList;

Formulario de Producto

src/pages/products/Create.js
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { createProduct } from '../../api/products';

const productSchema = Yup.object().shape({
    name: Yup.string().required('El nombre es requerido'),
    price: Yup.number()
        .required('El precio es requerido')
        .positive('El precio debe ser positivo'),
    stock: Yup.number()
        .integer('El stock debe ser un número entero')
        .min(0, 'El stock no puede ser negativo'),
    description: Yup.string(),
    category: Yup.string(),
});

const ProductCreate = () => {
    const [error, setError] = useState(null);
    const navigate = useNavigate();

    const formik = useFormik({
        initialValues: {
            name: '',
            description: '',
            price: 0,
            stock: 0,
            category: '',
        },
        validationSchema: productSchema,
        onSubmit: async (values) => {
            try {
                await createProduct(values);
                navigate('/products');
            } catch (err) {
                setError(err.message || 'Error al crear producto');
            }
        },
    });

    return (
        <div>
            <h1>Crear Producto</h1>
            {error && <div style={{ color: 'red' }}>{error}</div>}
            
            <form onSubmit={formik.handleSubmit}>
                <div>
                    <label>Nombre</label>
                    <input
                        type="text"
                        name="name"
                        value={formik.values.name}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.name && formik.errors.name && (
                        <div style={{ color: 'red' }}>{formik.errors.name}</div>
                    )}
                </div>
                
                <div>
                    <label>Descripción</label>
                    <textarea
                        name="description"
                        value={formik.values.description}
                        onChange={formik.handleChange}
                    />
                </div>
                
                <div>
                    <label>Precio</label>
                    <input
                        type="number"
                        name="price"
                        value={formik.values.price}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.price && formik.errors.price && (
                        <div style={{ color: 'red' }}>{formik.errors.price}</div>
                    )}
                </div>
                
                <div>
                    <label>Stock</label>
                    <input
                        type="number"
                        name="stock"
                        value={formik.values.stock}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.stock && formik.errors.stock && (
                        <div style={{ color: 'red' }}>{formik.errors.stock}</div>
                    )}
                </div>
                
                <div>
                    <label>Categoría</label>
                    <input
                        type="text"
                        name="category"
                        value={formik.values.category}
                        onChange={formik.handleChange}
                    />
                </div>
                
                <button type="submit">Guardar</button>
                <button type="button" onClick={() => navigate('/products')}>
                    Cancelar
                </button>
            </form>
        </div>
    );
};

export default ProductCreate;

5. Login y Registro

Componente de Login

src/pages/auth/Login.js
import { useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { AuthContext } from '../../context/AuthContext';

const loginSchema = Yup.object().shape({
    username: Yup.string().required('Usuario es requerido'),
    password: Yup.string().required('Contraseña es requerida'),
});

const Login = () => {
    const [error, setError] = useState(null);
    const { login } = useContext(AuthContext);
    const navigate = useNavigate();

    const formik = useFormik({
        initialValues: {
            username: '',
            password: '',
        },
        validationSchema: loginSchema,
        onSubmit: async (values) => {
            setError(null);
            const success = await login(values);
            if (success) {
                navigate('/products');
            } else {
                setError('Usuario o contraseña incorrectos');
            }
        },
    });

    return (
        <div>
            <h1>Iniciar Sesión</h1>
            {error && <div style={{ color: 'red' }}>{error}</div>}
            
            <form onSubmit={formik.handleSubmit}>
                <div>
                    <label>Usuario</label>
                    <input
                        type="text"
                        name="username"
                        value={formik.values.username}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.username && formik.errors.username && (
                        <div style={{ color: 'red' }}>{formik.errors.username}</div>
                    )}
                </div>
                
                <div>
                    <label>Contraseña</label>
                    <input
                        type="password"
                        name="password"
                        value={formik.values.password}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.password && formik.errors.password && (
                        <div style={{ color: 'red' }}>{formik.errors.password}</div>
                    )}
                </div>
                
                <button type="submit">Ingresar</button>
            </form>
            
            <p>
                ¿No tienes cuenta? <a href="/register">Regístrate aquí</a>
            </p>
        </div>
    );
};

export default Login;

Componente de Registro

src/pages/auth/Register.js
import { useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { AuthContext } from '../../context/AuthContext';

const registerSchema = Yup.object().shape({
    username: Yup.string()
        .required('Usuario es requerido')
        .min(3, 'Usuario debe tener al menos 3 caracteres'),
    email: Yup.string()
        .email('Email inválido')
        .required('Email es requerido'),
    password: Yup.string()
        .required('Contraseña es requerida')
        .min(6, 'Contraseña debe tener al menos 6 caracteres'),
});

const Register = () => {
    const [error, setError] = useState(null);
    const [success, setSuccess] = useState(false);
    const { register } = useContext(AuthContext);
    const navigate = useNavigate();

    const formik = useFormik({
        initialValues: {
            username: '',
            email: '',
            password: '',
        },
        validationSchema: registerSchema,
        onSubmit: async (values) => {
            setError(null);
            const success = await register(values);
            if (success) {
                setSuccess(true);
                setTimeout(() => navigate('/login'), 2000);
            } else {
                setError('Error al registrar usuario');
            }
        },
    });

    if (success) {
        return (
            <div>
                <h1>Registro Exitoso</h1>
                <p>Redirigiendo a la página de login...</p>
            </div>
        );
    }

    return (
        <div>
            <h1>Registro</h1>
            {error && <div style={{ color: 'red' }}>{error}</div>}
            
            <form onSubmit={formik.handleSubmit}>
                <div>
                    <label>Usuario</label>
                    <input
                        type="text"
                        name="username"
                        value={formik.values.username}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.username && formik.errors.username && (
                        <div style={{ color: 'red' }}>{formik.errors.username}</div>
                    )}
                </div>
                
                <div>
                    <label>Email</label>
                    <input
                        type="email"
                        name="email"
                        value={formik.values.email}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.email && formik.errors.email && (
                        <div style={{ color: 'red' }}>{formik.errors.email}</div>
                    )}
                </div>
                
                <div>
                    <label>Contraseña</label>
                    <input
                        type="password"
                        name="password"
                        value={formik.values.password}
                        onChange={formik.handleChange}
                        onBlur={formik.handleBlur}
                    />
                    {formik.touched.password && formik.errors.password && (
                        <div style={{ color: 'red' }}>{formik.errors.password}</div>
                    )}
                </div>
                
                <button type="submit">Registrarse</button>
            </form>
            
            <p>
                ¿Ya tienes cuenta? <a href="/login">Inicia sesión aquí</a>
            </p>
        </div>
    );
};

export default Register;

6. Componentes Reutilizables

Layout Principal

src/components/Layout.js
import { useContext } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';

const Layout = ({ children }) => {
    const { user, isAuthenticated, logout } = useContext(AuthContext);
    const navigate = useNavigate();

    const handleLogout = () => {
        logout();
        navigate('/login');
    };

    return (
        <div>
            <header>
                <nav>
                    <Link to="/">Inicio</Link>
                    
                    {isAuthenticated ? (
                        <>
                            <Link to="/products">Productos</Link>
                            <span>Bienvenido, {user?.username}</span>
                            <button onClick={handleLogout}>Cerrar sesión</button>
                        </>
                    ) : (
                        <>
                            <Link to="/login">Iniciar sesión</Link>
                            <Link to="/register">Registrarse</Link>
                        </>
                    )}
                </nav>
            </header>
            
            <main>
                {children}
            </main>
            
            <footer>
                <p>Sistema de Inventario © {new Date().getFullYear()}</p>
            </footer>
        </div>
    );
};

export default Layout;

Navbar con Material-UI (Opcional)

src/components/Navbar.js
import { useContext } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { 
    AppBar, 
    Toolbar, 
    Typography, 
    Button, 
    Box,
    Avatar
} from '@mui/material';
import { AuthContext } from '../context/AuthContext';

const Navbar = () => {
    const { user, isAuthenticated, logout } = useContext(AuthContext);
    const navigate = useNavigate();

    const handleLogout = () => {
        logout();
        navigate('/login');
    };

    return (
        <AppBar position="static">
            <Toolbar>
                <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
                    <Link to="/" style={{ color: 'white', textDecoration: 'none' }}>
                        Inventario App
                    </Link>
                </Typography>
                
                {isAuthenticated ? (
                    <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
                        <Button 
                            color="inherit"
                            component={Link}
                            to="/products"
                        >
                            Productos
                        </Button>
                        
                        <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
                            <Avatar sx={{ width: 32, height: 32 }}>
                                {user?.username?.charAt(0).toUpperCase()}
                            </Avatar>
                            <Typography variant="body1">
                                {user?.username}
                            </Typography>
                        </Box>
                        
                        <Button 
                            color="inherit"
                            onClick={handleLogout}
                        >
                            Cerrar sesión
                        </Button>
                    </Box>
                ) : (
                    <Box>
                        <Button 
                            color="inherit"
                            component={Link}
                            to="/login"
                        >
                            Iniciar sesión
                        </Button>
                        <Button 
                            color="inherit"
                            component={Link}
                            to="/register"
                        >
                            Registrarse
                        </Button>
                    </Box>
                )}
            </Toolbar>
        </AppBar>
    );
};

export default Navbar;

7. Ejecución y Pruebas

Ejecutar la Aplicación

Terminal
# Ejecutar el frontend
npm run dev

# Ejecutar el backend (desde el directorio del proyecto FastAPI)
uvicorn app.main:app --reload

Flujo de Trabajo

1. Usuario visita la aplicación

2. Si no está autenticado, es redirigido a /login

3. Después de login exitoso:

- Token JWT se almacena en localStorage

- Estado de autenticación se actualiza

- Usuario es redirigido a /products

4. En /products:

- Se hace petición GET a /products

- Axios añade automáticamente el token en el header

- Se muestra la lista de productos

5. Para operaciones CRUD:

- Cada acción dispara la petición correspondiente

- El estado de la UI se actualiza según la respuesta

Pruebas de Componentes

🔍 Ejemplo de prueba con Testing Library

src/__tests__/Login.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Login from '../pages/auth/Login';

describe('Login Component', () => {
    it('should render login form', () => {
        render(
            <BrowserRouter>
                <Login />
            </BrowserRouter>
        );
        
        expect(screen.getByLabelText('Usuario')).toBeInTheDocument();
        expect(screen.getByLabelText('Contraseña')).toBeInTheDocument();
        expect(screen.getByText('Ingresar')).toBeInTheDocument();
    });

    it('should show validation errors', async () => {
        render(
            <BrowserRouter>
                <Login />
            </BrowserRouter>
        );
        
        fireEvent.click(screen.getByText('Ingresar'));
        
        expect(await screen.findByText('Usuario es requerido')).toBeInTheDocument();
        expect(await screen.findByText('Contraseña es requerida')).toBeInTheDocument();
    });
});