Tauri+ReactでTODOアプリを作る


TauriV2 で簡単な TODO アプリを作ってみます。
今回作成したもの。
![tauri_settings_reactive.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552182/ec3d9a04-035c-d529-187b-5e7b3a2168c5.gif)

## 事前準備

- Rust のインストール
- Node.js のインストール

## プロジェクトの作成

以下のコマンドでプロジェクトを作成します。

```bash
npm create tauri-app@latest
cd todo
npm install
npm run tauri dev
```

作成時にはいくつかの質問がありますので、以下のように回答しています。

```bashs
✔ Project name · todo
✔ Identifier · com.todo.app
✔ Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, deno, bun)
✔ Choose your package manager · npm
✔ Choose your UI template · React - (https://react.dev/)
✔ Choose your UI flavor · JavaScript
```

コマンド実行後ウィンドウが立ち上がります。ちなみに、ホットリロードが効いているので、コードを修正すると自動でリロードされます。

![setup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552182/05d8204c-244c-3c1d-a5c8-ba24b9d6fa8a.png)

## chakra ui のインストール

今回デザインには Chakra UI を使用します。

```bash
npm i @chakra-ui/react @emotion/react
npx @chakra-ui/cli snippet add
```

main.jsx を以下のように変更し、ChakraProvider を追加します。

```jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { Provider } from "./components/ui/Provider";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <Provider>
      <App />
    </Provider>
  </React.StrictMode>
);
```

App.jsx を以下のように変更し、chalkra ui が適用されているか確認します。

```jsx
import { Button } from "./components/ui/Button";

function App() {
  return (
    <>
      <Button variant="outline">hello world</Button>
    </>
  );
}

export default App;
```

## Sqlite のインストール

タスクの管理には Sqlite を使用します。追加には以下のコマンドを実行します。

```bash
npm run tauri add sql
cd src-tauri
cargo add tauri-plugin-sql --features sqlite
```

cargo コマンドは、src-tauri ディレクトリで実行する必要があるため、注意してください。

## データベースの作成

src-tauri に sql ファイルを作成し、以下のように記述します。

```sql
CREATE TABLE task (
  -- 自動増分の主キー
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  -- タスクのタイトル
  title TEXT,
  -- 作成日時、デフォルトは現在時刻
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  -- 更新日時、デフォルトは現在時刻
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
  -- 論理削除フラグ、デフォルトは0（削除されていない）
  deleted INTEGER DEFAULT 0
);
```

sql ファイルを読み込むために、以下のように migratiosn.rs を作成します。

```rust
use tauri_plugin_sql::{Migration, MigrationKind};

pub fn get_migrations() -> Vec<Migration> {
    vec![Migration {
        version: 1,
        description: "Initial migration",
        sql: include_str!("./000_init.sql"),
        kind: MigrationKind::Up,
    }]
}
```

tauri のエントリポイント用のファイル libs.rs を以下のように変更します。今回は mydatabase.db という名前でデータベースを作成します。

```rust
mod migrations;

use migrations::migrations::get_migrations;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(
            tauri_plugin_sql::Builder::default()
                .add_migrations("sqlite:mydatabase.db", get_migrations())
                .build(),
        )
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

```

最後に、tauri.conf.json を以下のように変更します。

```json
{
  // ...
  "plugins": {
    "sql": {
      "preload": ["sqlite:mydatabase.db"]
    }
  }
}
```

また、フロントエンドから Sqlite を操作するための権限を付与します。権限は `src-tauri/capabilities/default.json` に記述します。

```json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default",
    "sql:default",
    "sql:allow-execute"
  ]
}
```

## CRUD の実装

本記事ではｍフロントエンドの javascript から Sqlite を操作しますが、フロントエンドから DB を直接操作するのはセキュリティ上の問題があるため、実際のアプリケーションでは Rust 側にロジックを隠蔽した方が良いかもしれません。

### クエリの実装

CRUD のためのクエリを以下のように実装します。

```js
import { SqlBase } from "./base";
import Database from "@tauri-apps/plugin-sql";

export class TaskDatabaseClient extends SqlBase {
  static TABLE_NAME = "task";
  #db;

  constructor(db) {
    super(db);
    this.#db = db;
  }

  static async load(path) {
    const db = await Database.load(path);
    return new TaskDatabaseClient(db);
  }

  async create(taskTitle) {
    const { lastInsertId } = await this.myExecute(
      `INSERT into ${TaskDatabaseClient.TABLE_NAME} (title) VALUES ($1)`,
      [taskTitle]
    );

    return { id: lastInsertId, title: taskTitle };
  }

  async readAll() {
    return this.mySelect(
      `SELECT * FROM ${TaskDatabaseClient.TABLE_NAME} WHERE deleted = false`
    );
  }

  async read(taskId) {
    return this.mySelect(
      `SELECT * FROM ${TaskDatabaseClient.TABLE_NAME} WHERE id = $1`,
      [taskId]
    );
  }

  async update(taskId, taskTitle) {
    console.log(taskId, taskTitle);
    await this.myExecute(
      `UPDATE ${TaskDatabaseClient.TABLE_NAME} SET title = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
      [taskTitle, taskId]
    );
  }

  async delete(taskId) {
    await this.myExecute(
      `UPDATE ${TaskDatabaseClient.TABLE_NAME} SET deleted = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
      [taskId]
    );
  }
}
```

### 画面の実装

画面の実装は、追加、編集、削除の機能を持つ簡単な TODO アプリです。まずはクエリを実装せずに、画面だけを実装します。
それぞれの機能は以下のように実装します。

```jsx
import { useState } from "react";
import { Stack, HStack, Input, VStack, Text } from "@chakra-ui/react";
import { Button } from "./components/ui/Button";

function App() {
  const [tasks, setTasks] = useState([]);
  const [taskInput, setTaskInput] = useState("");
  const [editInput, setEditInput] = useState("");
  const [editIndex, setEditIndex] = useState(null);

  const handleAddTask = () => {
    if (editIndex !== null) {
      const updatedTasks = tasks.map((task, index) =>
        index === editIndex ? taskInput : task
      );
      setTasks(updatedTasks);
      setEditIndex(null);
    } else {
      setTasks([...tasks, taskInput]);
    }
    setTaskInput("");
  };

  const handleEditTask = (index) => {
    setEditInput(tasks[index]);
    setEditIndex(index);
  };

  const handleUpdateTask = () => {
    console.log(editInput);
    const updatedTasks = tasks.map((task, index) =>
      index === editIndex ? editInput : task
    );
    setTasks(updatedTasks);
    setEditInput("");
    setEditIndex(null);
  };

  const handleDeleteTask = (index) => {
    const updatedTasks = tasks.filter((_, i) => i !== index);
    setTasks(updatedTasks);
  };

  return (
    <>
      <Stack spacing={4}>
        {/* form for task adding */}
        <HStack>
          <Input
            placeholder="Input Task"
            value={taskInput}
            onChange={(e) => setTaskInput(e.target.value)}
          />
          <Button onClick={handleAddTask}>Add</Button>
        </HStack>
        {/* task list */}
        <VStack spacing={2} align="stretch">
          {tasks.map((task, index) => (
            <HStack key={index} justify="space-between">
              {editIndex === index ? (
                <Input
                  value={editInput}
                  onChange={(e) => setEditInput(e.target.value)}
                />
              ) : (
                <Text>{task}</Text>
              )}
              <HStack>
                {editIndex === index ? (
                  <Button onClick={handleUpdateTask}>Save</Button>
                ) : (
                  <Button onClick={() => handleEditTask(index)}>Edit</Button>
                )}
                <Button onClick={() => handleDeleteTask(index)}>Delete</Button>
              </HStack>
            </HStack>
          ))}
        </VStack>
      </Stack>
    </>
  );
}

export default App;
```

ここまで以下のような画面となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552182/ca2c8cdf-a190-c590-8a86-76d585ef65a6.png)

### 画面からクエリの呼び出し

画面の実装ができたら、クエリを実装します。クエリの実装は以下のようになります。
DB 読み込み処理は、useEffect を使用して実装します。

```jsx
const [db, setDb] = useState(null);

useEffect(() => {
  const loadDatabase = async () => {
    const db = await TaskDatabaseClient.load("sqlite:mydatabase.db");
    setDb(db);
  };
  loadDatabase();
}, []);

useEffect(() => {
  const loadTasks = async () => {
    if (db) {
      const tasks = await db.readAll();
      setTasks(
        tasks.map((task) => {
          return { id: task.id, title: task.title };
        })
      );
    }
  };
  loadTasks();
}, [db]);
```

残りの CRUD 操作は以下のように実装します。

```jsx
const handleAddTask = async () => {
  const res = await db.create(taskInput);
  setTasks([...tasks, { id: res.id, title: taskInput }]);
  setTaskInput("");
};

const handleEditTask = (index) => {
  setEditInput(tasks.find((task) => task.id === index).title);
  setEditIndex(index);
};

const handleUpdateTask = async () => {
  await db.update(editIndex, editInput);
  const updatedTasks = tasks.map((task) =>
    task.id === editIndex ? { id: task.id, title: editInput } : task
  );
  setTasks(updatedTasks);
  setEditInput("");
  setEditIndex(null);
};

const handleDeleteTask = async (index) => {
  await db.delete(index);
  const updatedTasks = tasks.filter((task) => task.id !== index);
  setTasks(updatedTasks);
};
```

## まとめ

今回は Tauri を使って TODO アプリを作成しました。Tauri は Rust で書かれたデスクトップアプリケーションを作成するためのフレームワークですが、React と組み合わせることで多くの機能を JavaScript で実装できるため、簡単にデスクトップアプリケーションを作成できます。