Las operaciones CRUD son la base de cada aplicación, por lo que es esencial volverse competente en esto cuando se aprende nuevas tecnologías.

En este tutorial, aprenderás cómo construir una aplicación CRUD usando React y Convex. Cubriremos estas operaciones al construir un proyecto que se llama Book Collections. En este proyecto, los usuarios serán capaz de agregar libros y actualizar sus estados una vez que completan un libro.

Tabla de Contenido

¿Qué es Convex?

Convex es la Plataforma BaaS que simplifica el desarrollo backend. Convex viene con una base de datos de tiempo real, y no tienes que preocuparte sobre escribir lógica de parte del servidor de forma separada porque provee métodos para solicitar y mutar la base de datos.

Pre-requisitos

Para seguir este tutorial, debes conocer los fundamentos de React. Estaré usando TypeScript en este proyecto, pero es opcional, así que puedes seguirme con JavaScript.

Cómo configurar tu proyecto

Crea una carpeta separa para el proyecto y llámalo como gustes – yo lo llamaré Books. Configuraremos Convex y React en esa carpeta.

Puedes crear una aplicación de React usando este comando:

npm create vite@latest my-app -- --template react-ts

Si quieres trabajar con JavaScript, entonces quita el `ts` al final. Sería:

npm create vite@latest my-app -- --template react

Cómo configurar Convex

Tenemos que instalar Convex en la misma carpeta. Puedes hacer eso usando este comando:

npm install convex

Luego, ejecuta npx convex dev. Si lo estás haciendo por primera vez, te debería pedirte la autenticación. De otra forma, debería preguntarte por el nombre del proyecto.

Puedes visitar el panel de Convex para ver el proyecto que has creado.

Ahora que hemos configurado Convex y React, necesitamos conectar el backend de Convex a la aplicación de React.

En el src/main.tsx, envuelve tu componente App con ConvexReactClient:

import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import "./index.css"

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);

createRoot(document.getElementById("root")!).render(
  <ConvexProvider client={convex}>
    <App />
  </ConvexProvider>
);

Cuando configuras Convex, se crea un archivo .env.local. Puedes ver el URL de tu backend en ese archivo.

En la línea de abajo, instanciamos el Cliente de Convex de React con el URL.

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);

Cómo crear el Esquema

En tu carpeta principal del proyecto, deberías de ver la carpeta convex. Manejaremos las solicitudes de la base de datos y las mutaciones aquí.

Crea un archivo schema.ts en la carpeta convex:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  books: defineTable({
    title: v.string(),
    author: v.string(),
    isCompleted: v.boolean(),
  }),
});

Puedes definir un Esquema para tu documento con defineSchema y crea un tabla con defineTable. Convex provee estas funciones para definir un esquema y crear una tabla.

v es el validador de tipo, se usa para proveer tipos para cada dato que agregamos a la tabla.

Para este proyecto, ya que es una aplicación de colección de libros, la estructura tendrá title, author, y isCompleted. Puedes agregar mas campos.

Ahora que tienes definido tu esquema, configuremos la UI básica en React.

Cómo crear la UI

En la carpeta src, crea una carpeta llamada component y un archivo Home.tsx. Aquí, puedes definir la UI.

import { useState } from "react";
import "../styles/home.css";

const Home = () => {
  const [title, setTitle] = useState("");
  const [author, setAuthor] = useState("");
  return (
    <div className="main-container">
      <h1>Book Collections</h1>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          name="title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="book title"
        />
        <br />
        <input
          type="text"
          name="author"
          value={author}
          onChange={(e) => setAuthor(e.target.value)}
          placeholder="book author"
        />
        <br />
        <input type="submit" />
      </form>
      {books ? <Books books={books} /> : "Loading..."}
    </div>
  );
};

export default Home;

Puedes crear tu componente como gustes. Agregué dos campos input title, author, y un botón submit. Esta es la estructura básica. Ahora podemos crear los métodos CRUD en el backend.

Cómo crear las funciones CRUD

En la carpeta convex, puedes crear un archivo queries.ts separado para las funciones CRUD.

La Función Create

Ie convex/queries.ts:

Puedes definir una función createBooks. Puedes usar la función mutation de Convex para crear, actualizar, y eliminar datos. Leer los datos será a través de query.

La función mutation espera estos argumentos:

  • agrs: los datos que necesitamos almacenar en la base de datos.
  • handler: maneja la lógica para almacenar los datos en la base de datos. El handler es una función asíncrona, y tiene dos argumentos: ctx y args. Aquí, ctx es el objeto contexto que usaremos para manejar las operaciones de la base de datos.

Usarás el método insert para ingresar nuevos datos. El primer parámetro en el insert es el nombre de la tabla y el segundo es para los datos que necesitan ser insertados.

Como último, puedes regresar los datos desde la base de datos.

Aquí estás el código:

import { mutation} from "./_generated/server";
import { v } from "convex/values";

export const createBooks = mutation({
  args: { title: v.string(), author: v.string() },
  handler: async (ctx, args) => {
    const newBookId = await ctx.db.insert("books", {
      title: args.title,
      author: args.author,
      isCompleted: false,
    });
    return newBookId;
  },
});

La Función Read

En convex/queries.ts:

import { query } from "./_generated/server";
import { v } from "convex/values";

//read
export const getBooks = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query("books").collect();
  },
});

En esta operación read, usamos la función incorporada query de Convex. Aquí, args estará vacía ya que no obtenemos ningún datos del usuario. De forma similar, la función handler es asíncrona y usa el objeto ctx para solicitar de la base de datos y regresar los datos.

La Función Update

En convex/queries.ts:

Crea una función updateStatus. Vamos a actualizar solamente el estado isCompleted.

Aquí, necesitas obtener el ID del documento y el estado del usuario. En el args, definiremos el id y el isCompleted, los cuales vendrán del usuario.

En el handler, usaremos el método patch para actualizar los datos. El método patch espera dos argumentos: el primer argumento es el id del documento y el segundo es para los datos actualizados.

import { mutation } from "./_generated/server";
import { v } from "convex/values";

//update
export const updateStatus = mutation({
  args: { id: v.id("books"), isCompleted: v.boolean() },
  handler: async (ctx, args) => {
    const { id } = args;
    await ctx.db.patch(id, { isCompleted: args.isCompleted });
    return "updated"
  },
});

Función Delete

En convex/queries.ts:

Crea una función deleteBooks y usa la función mutation. Necesitaremos el ID del documento para que se elimine. En el args, define un ID. En el handler, usa el método delete del objeto ctx, y pasa el ID. Esto eliminará el documento.

import { mutation } from "./_generated/server";
import { v } from "convex/values";

//delete
export const deleteBooks = mutation({
  args: { id: v.id("books") },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id);
    return "deleted";
  },
});

A partir de ahora, has completado las funciones CRUD en el backend. Ahora necesitamos hacerlo funcionar en el UI. Volvamos a React.

Actualizar el UI

Ya has creado un UI básico en la aplicación de React, con algunos campos input. Vamos a actualizarlo.

En src/component/Home.tsx:

import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Books } from "./Books";
import { useState } from "react";
import "../styles/home.css";

const Home = () => {
  const [title, setTitle] = useState("");
  const [author, setAuthor] = useState("");
  const books = useQuery(api.queries.getBooks);
  const createBooks = useMutation(api.queries.createBooks);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault();
    createBooks({ title, author })
      .then(() => {
        console.log("created");
        setTitle("");
        setAuthor("");
      })
      .catch((err) => console.log(err));
  };
  return (
    <div className="main-container">
      <h1>Book Collections</h1>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          name="title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="book title"
        />
        <br />
        <input
          type="text"
          name="author"
          value={author}
          onChange={(e) => setAuthor(e.target.value)}
          placeholder="book author"
        />
        <br />
        <input type="submit" />
      </form>
      {books ? <Books books={books} /> : "Loading..."}
    </div>
  );
};

export default Home;

Ahora podemos usar las funciones API del backend al usar api de Convex. Como puedes ver, llamamos a dos funciones de la API: puedes usar useQuery si vas a obtener datos y useMutation si quieres cambiar los datos. Ahora en este archivo, que estamos haciendo, dos operaciones que son create y read.

Obtuvimos todos los datos al usar este método:

 const books = useQuery(api.queries.getBooks);

El arreglo de objetos serán almacenados en la variable books.

Obtuvimos la función create del backend con esta línea de código:

const createBooks = useMutation(api.queries.createBooks);

Cómo usar la función create en el UI

Usemos la función create en el UI.

Ya que los campos input están en la etiqueta form, usaremos el atributo onSubmit para manejar el envío del formulario.

// En el Home.tsx

const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault();
    createBooks({ title, author })
      .then(() => {
        console.log("created");
        setTitle("");
        setAuthor("");
      })
      .catch((err) => console.log(err));
  };

Cuando haces clic en submit, dispara la función handleSubmit.

Usamos el createBooks para pasar el title y author del estado. La función del endpoint es async, por lo que podemos usar el handleSubmit como async o usar .then. Usé el método .then para manejar los datos asíncronos.

Puedes crear un componente separado para mostrar los datos solicitados de la base de datos. Los datos regresados están en el Home.tsx, así que pasaremos los datos al componente Book.jsx como props.

En Books.tsx:

import { useState } from "react";
import { book } from "../types/book.type";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import "../styles/book.css";

export const Books = ({ books }: { books: book[] }) => {
  const [update, setUpdate] = useState(false);
  const [id, setId] = useState("");

  const deleteBooks = useMutation(api.queries.deleteBooks);
  const updateStatus = useMutation(api.queries.updateStatus);

  const handleClick = (id: string) => {
    setId(id);
    setUpdate(!update);
  };

  const handleDelete = (id: string) => {
    deleteBooks({ id: id as Id<"books"> })
      .then((mess) => console.log(mess))
      .catch((err) => console.log(err));
  };

  const handleUpdate = (e: React.FormEvent<HTMLFormElement>, id: string) => {
    e.preventDefault();
    const formdata = new FormData(e.currentTarget);
    const isCompleted: boolean =
      (formdata.get("completed") as string) === "true";
    updateStatus({ id: id as Id<"books">, isCompleted })
      .then((mess) => console.log(mess))
      .catch((err) => console.log(err));
    setUpdate(false);
  };

  return (
    <div>
      {books.map((data: book, index: number) => {
        return (
          <div
            key={data._id}
            className={`book-container ${data.isCompleted ? "completed" : "not-completed"}`}
          >
            <h3>Book no: {index + 1}</h3>
            <p>Book title: {data.title}</p>
            <p>Book Author: {data.author}</p>
            <p>
              Completed Status:{" "}
              {data.isCompleted ? "Completed" : "Not Completed"}
            </p>
            <button onClick={() => handleClick(data._id)}>Update</button>
            {id === data._id && update && (
              <>
                <form onSubmit={(e) => handleUpdate(e, data._id)}>
                  <select name="completed">
                    <option value="true">Completed</option>
                    <option value="false">Not Completed</option>
                  </select>
                  <input type="submit" />
                </form>
              </>
            )}
            <button onClick={() => handleDelete(data._id)}>delete</button>
          </div>
        );
      })}
    </div>
  );
};

En el componente Book.jsx, puedes mostrar los datos de la base de datos y manejar la funcionalidad para actualizar y eliminar los registros.

Veamos paso a paso cada una de estas características.

Cómo mostar los Datos

Puedes obtener los datos pasados como un prop en el componente Home.tsx. Si estás usando TypeScript, he definido un tipo para el objeto que es regresado de la solicitud. Puedes ignorar esto si estás usando JavaScript.

Crea books.types.ts:

export type book = {
    _id: string,
    title: string,
    author: string,
    isCompleted: boolean
}

Puedes usar la función map para mostrar los datos.

import { useState } from "react";
import { book } from "../types/book.type";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import "../styles/book.css";

export const Books = ({ books }: { books: book[] }) => {
  const [update, setUpdate] = useState(false);

  return (
    <div>
      {books.map((data: book, index: number) => {
        return (
          <div
            key={data._id}
            className={`book-container ${data.isCompleted ? "completed" : "not-completed"}`}
          >
            <h3>Book no: {index + 1}</h3>
            <p>Book title: {data.title}</p>
            <p>Book Author: {data.author}</p>
            <p>
              Completed Status:{" "}
              {data.isCompleted ? "Completed" : "Not Completed"}
            </p>
            <button onClick={() => handleClick(data._id)}>Update</button>
            {id === data._id && update && (
              <>
                <form onSubmit={(e) => handleUpdate(e, data._id)}>
                  <select name="completed">
                    <option value="true">Completed</option>
                    <option value="false">Not Completed</option>
                  </select>
                  <input type="submit" />
                </form>
              </>
            )}
            <button onClick={() => handleDelete(data._id)}>delete</button>
          </div>
        );
      })}
    </div>
  );
};

Esta es la estructura básica. Mostramos el título, autor, y estado, junto con un botón actualizar y eliminar.

Ahora, agreguemos las funcionalidades.

import { useState } from "react";
import { book } from "../types/book.type";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import "../styles/book.css";

export const Books = ({ books }: { books: book[] }) => {
  const [update, setUpdate] = useState(false);
  const [id, setId] = useState("");

  const deleteBooks = useMutation(api.queries.deleteBooks);
  const updateStatus = useMutation(api.queries.updateStatus);

  const handleClick = (id: string) => {
    setId(id);
    setUpdate(!update);
  };

  const handleDelete = (id: string) => {
    deleteBooks({ id: id as Id<"books"> })
      .then((mess) => console.log(mess))
      .catch((err) => console.log(err));
  };

  const handleUpdate = (e: React.FormEvent<HTMLFormElement>, id: string) => {
    e.preventDefault();
    const formdata = new FormData(e.currentTarget);
    const isCompleted: boolean =
      (formdata.get("completed") as string) === "true";
    updateStatus({ id: id as Id<"books">, isCompleted })
      .then((mess) => console.log(mess))
      .catch((err) => console.log(err));
    setUpdate(false);
  };

  return (
    <div>
      {books.map((data: book, index: number) => {
        return (
          <div
            key={data._id}
            className={`book-container ${data.isCompleted ? "completed" : "not-completed"}`}
          >
            <h3>Book no: {index + 1}</h3>
            <p>Book title: {data.title}</p>
            <p>Book Author: {data.author}</p>
            <p>
              Completed Status:{" "}
              {data.isCompleted ? "Completed" : "Not Completed"}
            </p>
            <button onClick={() => handleClick(data._id)}>Update</button>
            {id === data._id && update && (
              <>
                <form onSubmit={(e) => handleUpdate(e, data._id)}>
                  <select name="completed">
                    <option value="true">Completed</option>
                    <option value="false">Not Completed</option>
                  </select>
                  <input type="submit" />
                </form>
              </>
            )}
            <button onClick={() => handleDelete(data._id)}>delete</button>
          </div>
        );
      })}
    </div>
  );
};

Este es todo el código del componente. Déjame explicarte qué hice.

Primero, necesitamos alternar la actualización, así que definimos la función handleClick, y le pasamos un ID del documento.

//handleClick
 const handleClick = (id: string) => {
    setId(id);
    setUpdate(!update);
  };

En el handleClick puedes actualizar el estado del ID y alternar el estado de la actualización de forma que alternará la actualización cuando se haga clic, y en otro clic, se cerrará.

Luego, tenemos handleUpdate. Necesitamos el ID del documento para actualizar los datos, así que pasamos el objeto evento así también como el ID del documento. Para obtener la entrada, podemos usar FormData.

const updateStatus = useMutation(api.queries.updateStatus);

const handleUpdate = (e: React.FormEvent<HTMLFormElement>, id: string) => {
    e.preventDefault();
    const formdata = new FormData(e.currentTarget);
    const isCompleted: boolean =
      (formdata.get("completed") as string) === "true";
    updateStatus({ id: id as Id<"books">, isCompleted })
      .then((mess) => console.log(mess))
      .catch((err) => console.log(err));
    setUpdate(false);
  };

Necesitamos usar el useMutation para obtener la función updateStatus. Pasa el ID y el estado completado a la función, y maneja la parte asíncrona usando .then.

Para la función delete, el ID del documento es suficiente. Como el anterior, llama la función delete usando el useMutation y pásale el ID.

Luego pasa el ID del documento y maneja la promesa.

const deleteBooks = useMutation(api.queries.deleteBooks);

const handleDelete = (id: string) => {
    deleteBooks({ id: id as Id<"books"> })
      .then((mess) => console.log(mess))
      .catch((err) => console.log(err));
 };

Estilos

Finalmente, lo que queda es agregar algo de estilo. Agregué algunos estilos básicos. Si el libro no ha sido completado, estará en rojo, y si el libro se ha completado, estará en verde.

Aquí la captura de pantalla:

final output

¡¡Esto es todo chicos!!

Puedes visitar mi repositorio para ver todo el código: convex-curd

Resumen

En este artículo, implementamos las operaciones CRUD (Crear, Leer, Actualizar y Eliminar) al construir una aplicación de colecciones de libros. Comenzamos configurando Convex y React, y escribir la lógica de CRUD.

Este tutorial cubrió tanto el frontend como el backend, demostrando cómo construir una aplicación serverless.

Puedes encontrar todo el código aquí: convex-curd

Si hay algún error o cualquier duda, contáctame en LinkedIn, Instagram.

¡Gracias por leer!