SPA con MERN: ejemplo de aplicación web

En este artículo te enseñaré un ejemplo completo de conexión a MongoDB y Express con React. Vamos a usar el stack MERN para hacer un CRUD completo.

Al final tendremos un proyecto completo que será una single page application escrita con React, misma que consumirá una API de Node creada con Express. Los datos van a residir en una base de datos de MongoDB a la que vamos a acceder usando Mongoose.

Como siempre, te explicaré el código más importante a través del post, y te dejaré el repositorio completo al final del post para que puedas explorarlo a tu gusto.

Estructura del proyecto

Aquí solo te mostraré cómo creé el proyecto completo. Si tú tienes otro método puedes omitir este paso.

Primero generé la app de react con Create react app:

npx create-react-app ejemplo-mern
cd ejemplo-mern
npm start

En este caso se llama ejemplo-mern. Por defecto el servidor de desarrollo se ejecuta en el puerto 3000. Después en ese mismo directorio abrí otra terminal y para el lado del servidor usé el generador de express. Creé el proyecto con:

express --no-view --git api

Fíjate en que en este caso mi aplicación del servidor se llama api y está en el mismo directorio que mi app de react. Luego ejecuté:

cd api
npm install
SET DEBUG=api:* & npm start

Eso me lo dijo el asistente. Si a ti te dice otra cosa o sabes lo que haces, esto puede cambiar. Más tarde cambié el puerto, ya que el servidor de desarrollo de React escucha (al igual que la app de express por defecto) en el puerto 3000.

Para cambiarlo simplemente editamos el archivo bin/www. Yo lo cambié al puerto 5000:

#!/usr/bin/env node

/**
 * Module dependencies.
 */

var app = require('../app');
var debug = require('debug')('api:server');
var http = require('http');

/**
 * Get port from environment and store in Express.
 */

var port = normalizePort(process.env.PORT || '5000');
app.set('port', port);

Y ahora ejecutamos (dentro de api) npm run start o nodemon bin/www en caso de que quieras que el servidor se refresque en cada cambio que haces.

Solo para resumir: el servidor de desarrollo de React quedó en el puerto 3000 y el servidor de Node con Express en el puerto 5000. De este modo ambos proyectos están en el mismo directorio. La estructura final se ve así:

Estructura del proyecto MERN

Obviamente esa es la estructura ya con el código final. Ahora veamos cómo es que se conforma todo esto.

Por cierto, este proyecto se trata sobre la gestión de videojuegos. Algo muy simple pero que sirve perfecto como ejemplo.

Express con Node y MongoDB

Comencemos viendo el lado del servidor. La aplicación de Express queda así:

var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var enrutadorVideojuegos = require('./routes/videojuegos');

var app = express();

app.use((req, res, next) => {
    res.set("Access-Control-Allow-Credentials", "true");
    res.set("Access-Control-Allow-Origin", "http://localhost:3000");
    res.set("Access-Control-Allow-Headers", "Content-Type");
    res.set("Access-Control-Allow-Methods", "OPTIONS,GET,PUT,POST,DELETE");
    next();
});
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/videojuegos', enrutadorVideojuegos);

module.exports = app;

Solo he modificado lo necesario. Estamos cargando las rutas de los videojuegos en la línea 6. También estamos exponiendo la carpeta public (misma que se encuentra dentro de api) en la línea 21. Esto es importante, pues aquí vamos a colocar la app de React cuando la compilemos.

Además, estamos permitiendo CORS en la línea 10 para el dominio localhost:3000 ya que en modo desarrollo debemos permitir que React realice llamadas a la API de Express desde un dominio distinto. Si cambias el puerto del servidor de desarrollo de React, cámbialo aquí.

Después montamos las rutas de los videojuegos en /videojuegos, lo puedes ver en la línea 23.

Modelo de Mongoose

Antes de pasar a ver cómo creamos nuestra API que va a consumir React, veamos cómo es que nos conectamos a MongoDB usando Mongoose.

Mongoose no es otra cosa más que un tipo de ORM que nos permite definir modelos que más tarde interactúan con la base de datos de MongoDB.

En este caso el modelo queda definido así:

const mongoose = require('../conexion_mongo');
const Videojuego = mongoose.model('Videojuego', {
    nombre: {
        type: String,
        required: true,
    },
    precio: {
        type: Number,
        required: true,
        min: 0,
    },
    calificacion: {
        type: Number,
        required: true,
        min: 0,
    },
});

module.exports = Videojuego;

Como puedes ver solo tenemos 3 propiedades. En este caso es el nombre, el precio y la calificación del videojuego. También estamos indicando que los 3 campos son requeridos y que en el caso de los tipos numéricos el mínimo es 0.

Ya hemos definido nuestro modelo, es momento de exponerlo en una API.

Enrutador

Ahora veamos las rutas que van a conformar nuestra API. He definido a las mismas en un archivo separado que estoy importando anteriormente en la app principal. Fíjate cómo estoy importando el modelo definido anteriormente en la línea 3.

var express = require('express');
var router = express.Router();
var Videojuego = require("../modelos/modeloVideojuego.js");

router.post('/', async function (req, res, next) {
  const videojuego = new Videojuego({
    nombre: req.body.nombre,
    precio: req.body.precio,
    calificacion: req.body.calificacion,
  });
  await videojuego.save();
  res.send(videojuego);
});

router.get('/', async function (req, res) {
  const videojuegos = await Videojuego.find();
  res.send(videojuegos);
});

router.get('/:id', async function (req, res) {
  const videojuego = await Videojuego.findById(req.params.id);
  res.send(videojuego);
});

router.put('/', async function (req, res) {
  await Videojuego.findOneAndUpdate({
    _id: req.body._id,
  }, {
    nombre: req.body.nombre,
    precio: req.body.precio,
    calificacion: req.body.calificacion,
  });
  res.send(true);
});

router.delete('/:id', async function (req, res) {
  await Videojuego.findOneAndDelete({ _id: req.params.id });
  res.send(true);
});

module.exports = router;

Para cada ruta hago una operación en el modelo. Por ejemplo, en el método post agrego un nuevo registro, etcétera. Esto del modelo ya lo he explicado en otro post. Básicamente es hacer todo el CRUD pero separado en cada ruta de Express.

Recuerda que estamos montando este enrutador en /videojuegos y que las rutas son relativas. Así que por ejemplo para obtener todos los videojuegos vamos a hacer una petición GET a localhost:5000/videojuegos.

Lado del cliente con React

Pasemos a la parte de la aplicación web creada con la librería React y JavaScript. Vamos a consumir la API que expusimos anteriormente desde una Single page application.

He usado (además de React, obviamente) Bulma CSS para los estilos. Así que el index de la app web queda así:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import reportWebVitals from './reportWebVitals';
import "bulma/css/bulma.css";
import "./style.css";
import {
  HashRouter as Router,
} from "react-router-dom";
ReactDOM.render(
  <Router>
    <React.StrictMode>
      <App />
    </React.StrictMode>
  </Router>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Y la app general así:

import Nav from "./Nav";
import AgregarVideojuego from "./AgregarVideojuego";
import VerVideojuegos from "./VerVideojuegos";
import EditarVideojuego from "./EditarVideojuego";
import {
  Switch,
  Route,
} from "react-router-dom";

function App() {
  return (
    <div>
      <Nav></Nav>
      <div className="section">
        <div className="columns">
          <Switch>
            <Route path="/videojuegos/agregar">
              <AgregarVideojuego></AgregarVideojuego>
            </Route>
            <Route path="/videojuegos/editar/:id">
              <EditarVideojuego></EditarVideojuego>
            </Route>
            <Route path="/videojuegos/ver">
              <VerVideojuegos></VerVideojuegos>
            </Route>
            <Route path="/">
              <VerVideojuegos></VerVideojuegos>
            </Route>
          </Switch>
        </div>
      </div>
    </div>
  );
}

export default App;

Estoy usando el enrutador de React o React Router. Básicamente defino un componente por cada ruta. Aquí solo estoy definiendo el layout general y el menú de navegación. Fíjate en que en el caso del formulario para editar videojuegos (línea 20) se recibe el id en la ruta.

A continuación veremos los componentes, pero por el momento veamos el nav:

import React from 'react';
import logo from "./img/parzibyte_logo.png";
import { NavLink } from "react-router-dom";
class Nav extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            mostrarMenu: false,
        };
        this.intercambiarEstadoMenu = this.intercambiarEstadoMenu.bind(this);
        this.ocultarMenu = this.ocultarMenu.bind(this);
    }


    ocultarMenu() {
        this.setState({
            mostrarMenu: false,
        })
    }
    intercambiarEstadoMenu() {
        this.setState(state => {
            return {
                mostrarMenu: !state.mostrarMenu,
            }
        });
    }
    render() {
        return (
            <nav className="navbar is-warning" role="navigation" aria-label="main navigation">
                <div className="navbar-brand">
                    <a className="navbar-item" href="https://parzibyte.me/l/fW8zGd">
                        <img alt="" src={logo} style={{ maxHeight: "80px" }} />
                    </a>
                    <button onClick={this.intercambiarEstadoMenu} className={`navbar-burger ${this.state.mostrarMenu ? "is-active" : ""} is-warning button`} aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
                        <span aria-hidden="true"></span>
                        <span aria-hidden="true"></span>
                        <span aria-hidden="true"></span>
                    </button>
                </div>
                <div className={`navbar-menu ${this.state.mostrarMenu ? "is-active" : ""}`}>
                    <div className="navbar-start">
                        <NavLink onClick={this.ocultarMenu} activeClassName="is-active" className="navbar-item" to="/videojuegos/ver">Ver videojuegos</NavLink>
                        <NavLink onClick={this.ocultarMenu} activeClassName="is-active" className="navbar-item" to="/videojuegos/agregar">Agregar videojuego</NavLink>
                    </div>
                    <div className="navbar-end">
                        <div className="navbar-item">
                            <div className="buttons">
                                <a target="_blank" rel="noreferrer" href="https://parzibyte.me/l/fW8zGd" className="button is-primary">
                                    <strong>Soporte y ayuda</strong>
                                </a>
                            </div>
                        </div>
                    </div>
                </div>
            </nav>
        );
    }
}
export default Nav;

Es un nav de Bulma CSS. El código que ves es simplemente código para ocultar y mostrar el menú de navegación de bulma en pantallas más pequeñas. Ahora sí veamos los componentes.

Agregar videojuego

Formulario en React para agregar un nuevo videojuego en MongoDB

Veamos el primer componente de react. Es un simple formulario que nos va a servir para guardar un nuevo registro en nuestra base de datos de MongoDB ya en el backend. Lo renderizamos así:

render() {
    return (
        <div className="column is-one-third">
            <h1 className="is-size-3">Agregar videojuego</h1>
            <ToastContainer></ToastContainer>
            <form className="field" onSubmit={this.manejarEnvioDeFormulario}>
                <div className="form-group">
                    <label className="label" htmlFor="nombre">Nombre:</label>
                    <input autoFocus required placeholder="Nombre" type="text" id="nombre" onChange={this.manejarCambio} value={this.state.videojuego.nombre} className="input" />
                </div>
                <div className="form-group">
                    <label className="label" htmlFor="precio">Precio:</label>
                    <input required placeholder="Precio" type="number" id="precio" onChange={this.manejarCambio} value={this.state.videojuego.precio} className="input" />
                </div>
                <div className="form-group">
                    <label className="label" htmlFor="calificacion">Calificación:</label>
                    <input required placeholder="Calificación" type="number" id="calificacion" onChange={this.manejarCambio} value={this.state.videojuego.calificacion} className="input" />
                </div>
                <div className="form-group">
                    <button className="button is-success mt-2">Guardar</button>
                    &nbsp;
                    <Link to="/videojuegos/ver" className="button is-primary mt-2">Volver</Link>
                </div>
            </form>
        </div>
    );
}

En el change de cada input actualizamos el estado interno de la app:

manejarCambio(evento) {
    // Extraer la clave del estado que se va a actualizar, así como el valor
    const clave = evento.target.id;
    let valor = evento.target.value;
    this.setState(state => {
        const videojuegoActualizado = state.videojuego;
        // Si es la calificación o el nombre, necesitamos castearlo a entero
        if (clave !== "nombre") {
            valor = parseFloat(valor);
        }
        // Actualizamos el valor del videojuego, solo en el campo que se haya cambiado
        videojuegoActualizado[clave] = valor;
        return {
            videojuego: videojuegoActualizado,
        }
    });
}

Y también escuchamos el envío del formulario. Presta especial atención a este método, pues usando fetch vamos a enviar los datos del formulario a Express para que los mismos sean guardados en una base de datos de MongoDB:

async manejarEnvioDeFormulario(evento) {

    evento.preventDefault();
    // Codificar nuestro videojuego como JSON

    const cargaUtil = JSON.stringify(this.state.videojuego);
    // ¡Y enviarlo!
    const respuesta = await fetch(`${Constantes.RUTA_API}`, {
        method: "POST",
        body: cargaUtil,
        headers: {
            "Content-Type":"application/json",
        }
    });
    const exitoso = await respuesta.json();
    if (exitoso) {
        toast('Videojuego guardado 🎮', {
            position: "top-left",
            autoClose: 2000,
            hideProgressBar: false,
            closeOnClick: true,
            pauseOnHover: true,
            draggable: true,
            progress: undefined,
        });
        this.setState({
            videojuego: {
                nombre: "",
                precio: "",
                calificacion: "",
            }
        });
    } else {
        toast.error("Error guardando. Intenta de nuevo");
    }
}

La magia está ocurriendo desde la línea 6 hasta la 15. Lo demás es el manejo de la respuesta para mostrar un toast dependiendo de la respuesta.

Y así de simple es como enviamos un dato desde React a MongoDB. Después de eso limpiamos el formulario modificando el estado interno del componente.

El componente completo así como el código de toda la app te la dejaré al final, aquí solo te muestro lo más importante.

Obtener videojuegos

Tabla de datos traídos de Express y MongoDB renderizada con React

Ya tenemos nuestra API conectada a Mongo, es momento de consultar la ruta que nos va a dar todos los videojuegos en forma de arreglo. Para ello tenemos el siguiente componente que hace la petición cuando el mismo se acaba de montar (línea 13 hasta 18) y los muestra en la tabla gracias al método render.

import React from 'react';
import Constantes from "./Constantes";
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import FilaDeTablaDeVideojuego from './FilaDeTablaDeVideojuego';
class VerVideojuegos extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            videojuegos: [],
        };
    }
    async componentDidMount() {
        const respuesta = await fetch(`${Constantes.RUTA_API}`);
        const videojuegos = await respuesta.json();
        this.setState({
            videojuegos: videojuegos,
        });
    }
    render() {
        return (
            <div>
                <div className="column">
                    <h1 className="is-size-3">Ver videojuegos</h1>
                    <ToastContainer></ToastContainer>
                </div>
                <div className="table-container">
                    <table className="table is-fullwidth is-bordered">
                        <thead>
                            <tr>
                                <th>Nombre</th>
                                <th>Precio</th>
                                <th>Calificación</th>
                                <th>Editar</th>
                                <th>Eliminar</th>
                            </tr>
                        </thead>
                        <tbody>
                            {this.state.videojuegos.map(videojuego => {
                                return <FilaDeTablaDeVideojuego key={videojuego._id} videojuego={videojuego}></FilaDeTablaDeVideojuego>;
                            })}
                        </tbody>
                    </table>
                </div>
            </div>
        );
    }
}

export default VerVideojuegos;

En la línea 39 hasta la 41 estoy dibujando una fila de la tabla (componente propio) por cada videojuego que hay. A continuación vemos esa fila:

import React from 'react';
import { Link, Redirect } from 'react-router-dom';
import { toast } from 'react-toastify';
import Swal from 'sweetalert2';
import Constantes from './Constantes';
class FilaDeTablaDeVideojuego extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            // Si han eliminado este juego, no necesitamos mostrarlo
            eliminado: false,
        };
        this.redireccionarParaEditar = this.redireccionarParaEditar.bind(this);
        this.eliminar = this.eliminar.bind(this);
    }
    redireccionarParaEditar() {
        return <Redirect to={`/videojuegos/editar/${this.props.videojuego.id}`} />
    }
    async eliminar() {
        const resultado = await Swal.fire({
            title: 'Confirmación',
            text: `¿Eliminar "${this.props.videojuego.nombre}"?`,
            icon: 'question',
            showCancelButton: true,
            confirmButtonColor: '#3298dc',
            cancelButtonColor: '#f14668',
            cancelButtonText: 'No',
            confirmButtonText: 'Sí, eliminar'
        });
        // Si no confirma, detenemos la función
        if (!resultado.value) {
            return;
        }
        const respuesta = await fetch(`${Constantes.RUTA_API}/${this.props.videojuego._id}`, {
            method: "DELETE",
        });
        const exitoso = await respuesta.json();
        if (exitoso) {
            toast('Videojuego eliminado ', {
                position: "top-left",
                autoClose: 2000,
                hideProgressBar: false,
                closeOnClick: true,
                pauseOnHover: true,
                draggable: true,
                progress: undefined,
            });
            this.setState({
                eliminado: true,
            });
        } else {
            toast.error("Error eliminando. Intenta de nuevo");
        }
    }
    render() {
        if (this.state.eliminado) {
            return null;
        }
        return (
            <tr>
                <td>{this.props.videojuego.nombre}</td>
                <td>{this.props.videojuego.precio}</td>
                <td>{this.props.videojuego.calificacion}</td>
                <td>
                    <Link to={`/videojuegos/editar/${this.props.videojuego._id}`} className="button is-info">Editar</Link>
                </td>
                <td>
                    <button onClick={this.eliminar} className="button is-danger">Eliminar</button>
                </td>
            </tr>
        );
    }
}

export default FilaDeTablaDeVideojuego;

Decidí separar este componente de la fila de la tabla por simplicidad, ya que esta fila debe mostrar un enlace para editar el videojuego y un botón para eliminar, pero dejarlo en el componente padre haría que sea bastante código en un solo archivo.

Lo importante aquí es el botón para eliminar que muestra una confirmación de Sweet Alert 2 y en caso de que el usuario confirme, hace una petición DELETE a la API de Express para que a su vez ésta le diga a MongoDB que elimine el documento.

Eliminar documento de MongoDB desde React – Borrar videojuego

Editar documento de MongoDB con React

Formulario de edición – actualizar documento de MongoDB desde React

Ya vimos 3 de 4 operaciones fundamentales. Ahora veamos la última: la edición. En este caso necesitamos consumir dos endpoints de nuestra API.

El primer caso es para obtener los detalles de un videojuego por id (mismo que está en la URL) y rellenar el formulario:

async componentDidMount() {
    // Obtener ID de URL
    const idVideojuego = this.props.match.params.id;
    // Llamar a la API para obtener los detalles
    const respuesta = await fetch(`${Constantes.RUTA_API}/${idVideojuego}`);
    const videojuego = await respuesta.json();
    // "refrescar" el formulario
    this.setState({
        videojuego: videojuego,
    });
}

El segundo será cuando ya actualicemos el videojuego con una petición PUT, que será en el envío del formulario:

async manejarEnvioDeFormulario(evento) {

    evento.preventDefault();
    // Codificar nuestro videojuego como JSON

    const cargaUtil = JSON.stringify(this.state.videojuego);
    // ¡Y enviarlo!
    const respuesta = await fetch(`${Constantes.RUTA_API}/`, {
        method: "PUT",
        body: cargaUtil,
        headers: {
            "Content-Type": "application/json",
        }
    });
    const exitoso = await respuesta.json();
    if (exitoso) {
        toast('Videojuego guardado 🎮', {
            position: "top-left",
            autoClose: 2000,
            hideProgressBar: false,
            closeOnClick: true,
            pauseOnHover: true,
            draggable: true,
            progress: undefined,
        });

    } else {
        toast.error("Error guardando. Intenta de nuevo");
    }
}

Y hacemos lo mismo que cuando insertamos un nuevo videojuego, indicamos el éxito con un toast pero en este caso no reiniciamos el formulario.

Código completo

A lo largo del post solo te he explicado los aspectos más importantes, pero no te he mostrado el código completo pues el mismo reside en mi repositorio de GitHub.

Por ejemplo, pudiste ver que importamos el archivo de las constantes, mismo que guarda (y debes cambiarlo en caso necesario) la URL del servidor pero que no lo he mostrado aquí por simplicidad.

Te dejo el código fuente completo en mi GitHub. Recuerda que tanto para la api como para el front-end necesitas ejecutar npm install para instalar las dependencias.

Luego, en ambos lados debes ejecutar (en terminales separadas) npm run start para ejecutar tanto el servidor de la API como el servidor de desarrollo de React.

Esto solo es cuando estés en modo desarrollo, pues cuando pases a producción vas a compilar la app de React y servir los assets con Node, cosa que veremos a continuación.

Preparando para producción

Cuando hayas terminado de modificar la app del lado del cliente (con React) puedes generar un build optimizado. Para ello ejecuta npm run build.

Ese comando va a generar una carpeta llamada build; copia el contenido de la misma en api/public de manera que se ve así:

Desplegar app MERN

Ahora ya no es necesario encender el servidor de desarrollo de React, pues basta con tener el servidor de Node que alberga tanto la API como los archivos estáticos de la SPA.

De hecho si accedes a localhost:5000 verás la aplicación que creamos con React, ya sin tener el servidor de desarrollo:

SPA MERN – Sirviendo archivos estáticos y API con Express y Node

Así es como puedes llevar la app a producción y servir los archivos de manera estática. Eso sí, cuando quieras modificar algo del lado del cliente tendrás que encender el servidor de React, volver a “compilar” y a colocar los archivos en public.

Conclusión

Sé que fue un post algo extenso pero quería asegurarme de dejar todo claro, tanto para descargar el código, ejecutarlo y llevarlo a producción.

Si te interesa también puedes ver cómo conectar PHP con React, o más tutoriales de React, JavaScript y MongoDB.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

3 comentarios en “SPA con MERN: ejemplo de aplicación web”

  1. Muchas gracias por esta entrada. La aplicación la he descargado y funciona, está bien explicado en el blog y es un gusto poder aprender algo así con este detalle y funcionando.

    Solo por si sirve de ayuda para otros:
    – Habría que poner al principio que hace falta un MongoDB (con docker se solventa perfectamente, exportando el puerto 27017 a local).
    – Hace falta una versión más antigua de la actual. Yo he instado Node.js con nvm, la versión 14.21.2, y funciona perfectamente. Con la versión LTS no va.
    – Hay que hacer npm install tanto en /api como en la app principal de React.

    Muchas gracias y un saludo.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *