Operações CRUD são a base de todas as aplicações, portanto, é essencial tornar-se proficiente nelas quando aprendendo novas tecnologias.

Neste tutorial, você vai aprender a construir uma aplicação CRUD usando React e Convex. Vamos cobrir essas operações construindo um projeto chamado Coleções de livros. Neste projeto, os usuários serão capazes de adicionar livros e atualizar seu status assim que completarem um livro.

Sumário

O que é Convex?

Convex é a plataforma Baas que simplifica o desenvolvimento de backend. Convex vem com uma base de dados em tempo real, e você não precisa se preocupar com a escrita de lógica de servidor separadamente, pois ele fornece métodos para consultar e mutar o banco de dados.

Pré-requisitos

Para seguir este tutorial, você deve saber os fundamentos do React. Eu usarei TypeScript neste projeto, mas é opcional, portanto, você pode seguir com JavaScript também.

Como Configurar Seu Projeto

Crie um separado pasta para o projeto e nomeie-a como desejar – eu vou chamar de Livros. Vamos configurar o Convex e o React nesta pasta.

Você pode criar um aplicativo React usando este comando:

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

Se você quiser trabalhar com JavaScript, então remova o ts no final. É assim:

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

Como Configurar o Convex

Temos que instalar o Convex na mesma pasta. Você pode fazer isso usando este comando:

npm install convex

Agora, execute npx convex dev. Se você estiver fazendo isso pela primeira vez, deve pedir autenticação. Caso contrário, deve pedir o nome do projeto.

Você pode visitar o painel do Convex para ver os projetos que você criou.

Agora que nós temos o Convex e o aplicativo React configurados, precisamos conectar o backend do Convex à aplicação React.

No src/main.tsx, envolva seu componente App com o 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>
);

Quando você configurou o Convex, um arquivo .env.local foi criado. Você pode ver sua URL de backend nesse arquivo.

Na linha abaixo, nós instanciamos o cliente React Convex com a URL.

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

Como criar o esquema

Em seu diretório de projeto principal, você deveria ver a pasta convex. Aqui nós lidaremos com as consultas e mutações de banco de dados.

Crie um arquivo schema.ts na pasta 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(),
  }),
});

Você pode definir um esquema para o seu documento com defineSchema e criar uma tabela com defineTable. O Convex fornece essas funções para definir um esquema e criar uma tabela.

v é o validador de tipo, ele é usado para fornecer tipos para cada dado que você adiciona à tabela.

Para esse projeto, já que é um aplicativo de coleção de livros, a estrutura terá title, author e isCompleted. Você pode adicionar mais campos.

Agora que você definiu seu esquema, vamos configurar a interface básica em React.

Como criar a interface de usuário

Na pasta src, crie uma pasta chamada component e um arquivo Home.tsx. Aqui, você pode definir a 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;

Você pode criar seu componente da forma que desejar. Eu adicionei dois campos de entrada title e author, e um botão submit. Esta é a estrutura básica. Agora podemos criar métodos CRUD no backend.

Como Criar Funções CRUD

Na pasta convex, você pode criar um arquivo separado queries.ts para as funções CRUD.

Criar Função

No arquivo convex/queries.ts:

Você pode definir uma função createBooks. Você pode usar a função mutation do Convex para criar, atualizar e excluir dados. A leitura de dados ficará sob query.

A função mutation espera esses argumentos:

  • agrs: os dados que precisamos armazenar no banco de dados.

  • handler: manipula a lógica para armazenar dados no banco de dados. O handler é uma função assíncrona e tem dois argumentos: ctx e args. Aqui, ctx é o objeto de contexto que usaremos para lidar com as operações de banco de dados.

Você usará o método insert para inserir novos dados. O primeiro parâmetro do insert é o nome da tabela e o segundo é os dados que precisam ser inseridos.

Finalmente, você pode retornar os dados do banco de dados.

Aqui está o 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;
  },
});

Leitura de Função

No arquivo 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();
  },
});

Nessa operação de leitura, nós usamos a função interna query do Convex. Aqui, o args estará vazio já que nós não estamos obtendo nenhum dado do usuário. Similarmente, a função handler é assíncrona e usa o objeto ctx para consultar o banco de dados e retornar os dados.

Função de Atualização

No arquivo convex/queries.ts:

Crie uma função updateStatus. Nós vamos atualizar somente o status isCompleted.

Aqui, você precisa obter o ID do documento e o status do usuário. No args, nós definiremos id e isCompleted, que virão do usuário.

Na função handler, nós usaremos o método patch para atualizar os dados. O método patch espera dois argumentos: o primeiro é o ID do documento e o segundo é os dados atualizados.

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"
  },
});

Função de Exclusão

No arquivo convex/queries.ts:

Crie uma função deleteBooks e use a função mutation. Precisaremos do ID do documento para deletar. No args, defina um ID. No handler, use o método delete do objeto ctx e passe o ID. Isso fará o delete do 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";
  },
});

Até agora, você concluiu as funções CRUD no backend. Agora precisamos fazer com que funcione na UI. Vamos voltar para o React.

Atualize a UI

Você já criou alguma UI básica na aplicação React, juntamente com alguns campos de entrada. Vamos atualizá-la.

Em 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;

Agora podemos usar as funções de backend API usando api de Convex. Como você pode ver, chamamos duas funções de API: você pode usar useQuery se você vai ler dados e useMutation se você quer mudar dados. Agora neste arquivo, estamos fazendo duas operações que são criar e ler.

Obtivemos todos os dados usando este método:

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

O array de objetos será armazenado na variável books.

Obtivemos a função de criação do backend com esta linha de código:

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

Como Usar a Função de Criação na UI

Vamos usar a função de criação na UI.

Como os campos de entrada estão dentro da tag form, vamos usar o atributo onSubmit para tratar a submissão do formulário.

//In the 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));
  };

Ao clicar em enviar, ele dispara a função handleSubmit.

Nós usamos a createBooks para passar o title e o author do estado. A função de ponto final é assíncrona, portanto, nós podemos usar a handleSubmit como assíncrona ou usar .then. Eu usei o método .then para lidar com dados assíncronos.

Você pode criar um componente separado para exibir os dados fetchados do banco de dados. Os dados retornados estão no Home.tsx, portanto, nós passaremos os dados para o componente Books.tsx como props.

No 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>
  );
};

No componente Books.jsx, você pode exibir dados do banco de dados e lidar com a funcionalidade de atualização e exclusão de registros.

Vamos passar por cada uma destas funcionalidades passo a passo.

Como Exibir os Dados

Você pode obter os dados passados como um prop no componente Home.tsx. Se você estiver usando TypeScript, eu defini um tipo para o objeto que é retornado da consulta. Você pode ignorar isso se estiver usando JavaScript.

Crie `books.types.ts`:

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

Você pode usar a função map para exibir os dados.

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 é a estrutura básica. Exibimos o título, o autor e o status, juntamente com um botão de atualizar e excluir.

Agora, vamos adicionar as 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 é todo o código do componente. Deixe-me explicar o que fizemos.

Primeiro, precisamos alternar a atualização, então definimos a função handleClick e passamos um ID de documento para ela.

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

Na handleClick, você pode atualizar o estado do ID e alternar o estado de atualização, assim quando clicado, ele alternará a entrada de atualização e com outro clique, ele fechará.

A seguir, temos o handleUpdate. Precisamos do ID de documento para atualizar os dados, então passamos o objeto de evento e o ID de documento. Para obter o input, 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);
  };

Nós precisamos usar o useMutation para obter a função updateStatus. Passar o ID e o status concluído para a função e manipular a parte assíncrona usando .then

Para a função de excluir, o ID de documento é suficiente. Exatamente como no anterior, chame a função de excluir usando o useMutation e passe o ID para ela.

Então passe o ID de documento e manipule a promessa.

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));
 };

Estilização

Finalmente, o que resta é adicionar algum estilo. Eu adicionei alguns estilos básicos. Se o livro não for concluído, estará em vermelho, e se o livro for concluído, estará em verde.

Aqui está a captura de tela:

É isso aí pessoal!!

Você pode ver o código completo no meu repositório: convex-curd

Resumo

Neste artigo, implementamos as operações CRUD (Criar, Ler, Atualizar e Excluir) construindo uma aplicação de coleções de livros. Começamos configurando o Convex e o React e escrevendo a lógica CRUD.

Este tutorial abrangeu tanto o frontend quanto o backend, mostrando como construir uma aplicação sem servidor.

Você pode encontrar o código completo aqui: convex-curd

Se houver algum erro ou dúvida, contate-me no LinkedIn, Instagram.

Obrigado por ler!