Операции CRUD являются основой каждой приложения, поэтому важно стать профессионалом в их освоении при изучении новых технологий.

В этой учебнике вы узнаете, как создать приложение CRUD с использованием React и Convex. Мы покроем эти операции, создавая проект под названием “Коллекции книг”. В этом проекте пользователи смогут добавлять книги и обновлять их статус после того, как они прочитают книгу.

Contents

Что такое Convex?

Convex — это платформа Baas, упрощающая разработку backend-части. Convex включает реальную базу данных и не требует отдельного написания server-side логики, так как предоставляет методы для запроса и изменения базы данных.

Предварительные требования

Чтобы следовать этому руководству, вам необходимо знать основы React. В этом проекте я буду использовать TypeScript, но это не обязательно, поэтому вы также можете следовать примеру с JavaScript.

Как создать ваш проект

Создайте отдельную папку для проекта и назовите ее как хотите — я назову свою Книги. Мы будем устанавливать Convex и React в этой папке.

Вы можете создать React-приложение с помощью этого команды:

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

Если вы хотите работать с JavaScript, уберите ts в конце. Тогда:

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

Как установить Convex

Мы должны установить Convex в той же папке. Вы можете это сделать с помощью этой команды:

npm install convex

Потом запустите npx convex dev. Если это первый раз, то она должна спросить о аутентификации. В противном случае, она должна спросить о названии проекта.

Вы можете посетить панель управления Convex, чтобы увидеть созданные вами проекты.

Теперь, когда мы установили Convex и React App, нам нужно соединить backend Convex с React-приложением.

В src/main.tsx оберните ваш компонент App тегами 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>
);

Когда вы устанавливаете Convex, вам создается .env.local. URL backend вы можете увидеть в этом файле.

В следующей строке мы инициализируем React-клиент Convex с URL-ом.

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

Как создать схему

В основном каталоге вашего проекта должен быть каталог convex. Здесь мы будем обрабатывать запросы к базе данных и мутации.

Создайте файл schema.ts в каталоге 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(),
  }),
});

Вы можете определить схему документа с помощью defineSchema и создать таблицу с помощью defineTable. Convex предоставляет эти функции для определения схемы и создания таблицы.

v является типом валидатора, который используется для предоставления типов для каждого добавляемого нам данного в таблицу.

Для этого проекта, являясь приложением для коллекции книг, структура будет состоять из title, author и isCompleted. Вы можете добавить больше полей.

Теперь, когда вы определили свою схему, давайте настроим базовое UI в React.

Как создать UI

В папке src создайте папку с именем component и файл с именем Home.tsx. Здесь вы можете определить 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;

Вы можете создавать свои компоненты как угодно. Я добавил два поля ввода title и author, а также кнопку submit. Это основная структура. Теперь мы можем создавать CRUD методы на backend.

Как создавать CRUD функции

В папке convex можно создать отдельный файл queries.ts для CRUD функций.

Создание функции

В convex/queries.ts:

Вы можете определить функцию createBooks. Вы можете использовать функцию mutation из Convex для создания, обновления и удаления данных. Чтение данных будет осуществляться с помощью query.

Функция mutation ожидает следующих аргументов:

  • agrs: данные, которые нам нужно сохранить в базе данных.

  • handler: обрабатывает логику хранения данных в базе данных. Функция handler является асинхронной, и у нее есть два аргумента: ctx и args. Здесь ctx – это объект контекста, который мы будем использовать для операций с базой данных.

Вы будете использовать метод insert для добавления новых данных. Первым параметром в insert является имя таблицы, а вторым – данные, которые нужно добавить.

Конечно, вы можете извлечь данные из базы данных.

Вот код:

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

Read Function

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

В этом операции чтения мы использовали встроенную функцию query из Convex. Здесь args будет пустым, так как мы не получаем данные от пользователя. Аналогично, функция handler является асинхронной и использует объект ctx, чтобы выполнить запрос в базу данных и возвратить данные.

Update Function

В convex/queries.ts:

Создайте функцию updateStatus. Мы будем обновлять только статус isCompleted.

Здесь вам нужно получить идентификатор документа и статус от пользователя. В args мы определим id и isCompleted, которые будут получены от пользователя.

В handler мы будем использовать метод patch, чтобы обновить данные. Метод patch ожидает два аргумента: первым аргументом является идентификатор документа, а вторым – обновленные данные.

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

Delete Function

В convex/queries.ts:

Создайте функцию deleteBooks и используйте функцию mutation. Нам потребуется ID документа, который будет удален. В args определите ID. В handler используйте метод delete объекта ctx и передайте ID. Это удалит документ.

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

К настоящему моменту вы завершили CRUD функции на backend. Теперь нам нужно сделать так, чтобы это работало на UI. Перейдем к React.

Обновите UI

Вы уже создали основной UI в React app, а также несколько полей ввода. Покажем, как его обновить.

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

Теперь мы можем использовать backend API функции, используя api из Convex. Как вы можете видеть, мы вызвали две API функции: вы можете использовать useQuery, если вы будете читать данные, и useMutation, если вы хотите изменять данные. В этом файле мы выполняем два операции: создание и чтение.

Мы получили все данные с помощью этого метода:

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

Массив объектов будет сохранен в переменной books.

Мы получили функцию create с backend с помощью этой строки кода:

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

Как использовать Create Function в UI

Покажем, как использовать create function в UI.

Так как поля ввода находятся в form теге, мы будем использовать атрибут onSubmit, чтобы обрабатывать отправку формы.

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

Когда вы нажимаете кнопку submit, она вызывает функцию handleSubmit.

Мы использовали createBooks, чтобы передать title и author из состояния. Функция конечного пункта является асинхронной, поэтому мы можем использовать handleSubmit как асинхронную или использовать .then. Я использовал метод .then, чтобы обработать асинхронные данные.

Вы можете создать отдельный компонент для отображения данных, загруженных из базы данных. Возвращенные данные содержатся в Home.tsx, поэтому мы будем передавать данные компоненту Books.tsx как свойства.

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

В компоненте Books.jsx вы можете отобразить данные из базы данных и обработать функциональность для обновления и удаления записей.

Давайте разглянем каждую из этих функций шаг за шагом.

Как отобразить данные

Вы можете получить данные, переданные как свойство, в компоненте Home.tsx. Если вы используете TypeScript, я определил тип для объекта, который возвращается от запроса. Вы можете проигнорировать это, если вы используете JavaScript.

Создайте `books.types.ts:

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

Вы можете использовать функцию map для отображения данных.

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

Это базовая структура. Мы отобразили название, автора и статус, а также кнопки обновления и удаления.

Теперь добавим функциональности.

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

Это весь код компонента. Permit me to explain what we did.

Первым делом нам нужно включить или выключить обновление, поэтому мы определили функцию handleClick и передали ей идентификатор документа.

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

В функции handleClick вы можете обновить состояние идентификатора и переключить состояние обновления, чтобы, когда кликнете, переключить ввод обновления, и на следующем клике закрыть его.

Далее у нас есть handleUpdate. Нам нужен идентификатор документа, чтобы обновить данные, поэтому мы также передаем объект события и идентификатор документа. Чтобы получить ввод, мы можем использовать 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);
  };

Нам нужно использовать useMutation, чтобы получить функцию updateStatus. Передайте идентификатор и статус завершения функции и обработайте асинхронную часть с помощью .then

Для функции удаления достаточно идентификатора документа.actly as the previous one, call the delete function using the useMutation and pass the ID to it.

Then pass the document ID and handle the promise.

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

Стилизация

Конечно, остальным что осталось, это добавить несколько стилей. Я добавил несколько базовых стилей. Если книга не завершена, она будет красной, а если книга завершена, она будет зеленой.

Вот скриншот:

Вот это все, ребята!!

Вы можете посмотреть мой репозиторий с полным кодом: convex-curd

Резюме

В этой статье мы реализовали операции CRUD (Create, Read, Update, Delete) путём создания приложения для коллекции книг. Мы начинаем с настройки Convex и React и написания логики CRUD.

Этот учебник охватывает как frontend, так и backend, демонстрируя, как создавать серверлоссное приложение.

Вы можете найти полный код здесь: convex-curd

Если есть какие-либо ошибки или сомнения, свяжитесь с мной на LinkedIn, Instagram.

Спасибо за чтение!