【図解解説/入門】ReactとJotaiを使って実用的なNotion風ノートアプリを開発するチュートリアル【Convex/TypeScript/MDXEditor】

![ReactとJotaiを使って実用的なNotion風ノートアプリを開発するチュートリアル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/9f9a62c4-0433-d477-40bc-996873f5aa49.png)


# はじめに
　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d1777513-dba5-8649-64e6-3f4fe6720477.png)

こんにちは、Watanabe Jin([@Sictu_study](https://x.com/Sicut_study))です。
2025年に入ってReact界隈に衝撃が走りました。これまでメンテナンスが全然されていなかった状態管理ライブラリ『Recoil』のプロジェクトが凍結されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/075c341c-294b-a323-69c2-3bf49074b386.png)

https://github.com/facebookexperimental/Recoil


2023年にMeta社のレイオフをきっかけにメンテナンスが半年以上されなくなっていたところに、**React19**が登場して対応できなくなった結果完全にRecoilはなくなりました。
　

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ad271beb-8656-4f9d-1d6e-7f8fad87cae8.png)

Recoilは使いやすくMeta社が開発していたこともあり多くのプロジェクトで採用されていたため、代替に乗り換える必要がでてきてしまったのです。

>RecoilがReact19で動かないらしいからJotaiに移行しないと

> 前職のプロジェクトRecoilたくさん使ってたから大変だろうな…

このような声がSNSには多く上がっています。
そんな中、Recoilの代替として選ばれているのが<font color="red">**Jotai**です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/f781c837-bcc6-b30b-6e33-6702b53ca2ad.png)


今回のチュートリアルでは最近注目されている**Jotai**を使って実用的な**Notion風ノートアプリ**を開発していきます。

![名称未設定のデザイン.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a61191e3-e2bd-e4a2-9b1f-6ece2d6e3f48.gif)


- Reactの基本機能の解説
- Jotaiを使った状態管理
- ConvexのDBを使ったデータの永続化
- コードブロック機能
- コード補完機能

などをアプリ開発を通して学べます。
アプリも実用的になっているのでデプロイすれば日常で活用できます。

# 動画教材も用意しています
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材ではわからない細かい箇所があれば動画も活用ください

<iframe width="560" height="315" src="https://www.youtube.com/embed/4NOy1XmICkg?si=66GgEGO8QLW2RoVh" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://github.com/jinwatanabe/jotai-note-app

# 対象者

- 状態管理について学びたい
- Jotaiに乗り換えをしたい
- Reactがなんとなくわかる
- Convexを使って型安全にDBを使いたい
- 実用的なアプリを開発して学びたい

**このチュートリアルはHTMLと基本的なJavaScriptがわかる方で2時間程度で行うことができます。**

# Jotaiって何？

ここでは状態管理ライブラリ「Jotai」を解説していきますが、その前にそもそも**状態管理ライブラリ**の役割から解説していきます。
　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/bfacd6d4-dfec-f210-8812-216adf93156c.png)

まずは状態管理ライブラリがない世界での例を考えていきます。
例えば親コンポーネント1が今回表示する情報(データ)をAPIを叩いて取得したとします。しかし、実際にデータを表示する処理が書かれているのは**コンポーネント5**です。

そこで、コンポーネント1 -> コンポーネント2 -> ... -> コンポーネント5とデータをバケツリレーのように渡していって最後にコンポーネント5でデータを使って画面表示を行います。

>コンポーネント1からコンポーネント5に直接渡したいな…

そう思うはず。それを実現してくれるのが`状態管理ライブラリ`なのです。
　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/3d909122-8f53-836f-7693-6b23b3e77232.png)

状態管理ライブラリを使うことによって**グローバルステート**というどこからでもアクセス可能なデータ保管庫を作ることができます。
こうすることで必要なコンポーネント(ここではコンポーネント5)からデータを直接保管庫から読み取って使うことができます。
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/68592800-53c0-52f6-cea1-3819fbc63844.png)

この仕組みを実現するのが状態管理ライブラリである**Jotai**です。
世の中に状態管理ライブラリは色々ありますし、Reactにも標準で`useContext`という状態管理の仕組みが用意されています。

ここでJotaiが注目される3つの特徴を紹介します。

- Recoilにインスパイアされているので使用感が近い
- useState感覚で利用できるのでキャッチアップがしやすい
- 状態管理ライブラリ3部作の1つで作者は日本人

このような点からJotaiはRecoilの代替えとして選ばれています。

# Jotaiの基本機能の解説

チュートリアルをする前にJotaiの基本的な使い方を解説します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/6b5257c3-85de-055a-d7a3-35cadb9a6a59.png)

このようにストア(保管庫)を用意しておきます。
こうすることでどこからでもこのストアをインポートして中身を取り出し利用することができます。

```ts
export const countAtom = atom(0)
```

```ts
const [count, setCount] = useAtom(countAtom)
```

`count`が実際にcountAtomに保存されている値、`setCount`がcountAtomの値を変更する関数です。

```ts
<button onClick={() => setCount(c => c + 1)}>
```

更新の方法は`useState`と変わらないので同じ感覚で利用できます。
Jotaiには**派生Atom**という考え方があるのでこちらも紹介します。
　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/9909388c-bf5e-5848-953a-113a438125d8.png)

```ts
const doubleAtom = atom(get => get(countAtom) * 2)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const [double] = useAtom(doubleAtom)
```

読み取りAtomは`double`のようなもので、doubleは`count`を2倍にした値を常に返します。**doubleAtom**を使ってdoubleの更新(setDoubleAtomのようなもの)はできないので読み取り専用と言えます。

doubleを更新するには`count`を更新するしか方法がありません。

```ts
const doubleAtom = atom(
 get => get(countAtom) * 2,  // 読み取り
 (get, set, newDouble) => set(countAtom, newDouble / 2)  // 書き込み
)

function Counter() {
 const [count, setCount] = useAtom(countAtom)
 const [double, setDouble] = useAtom(doubleAtom)
```

それに対して読み書き可能AtomであればdoubleAtomを使って更新ができます。(setDoubleを使う)

# Convexとは？

最後に今回DBとして利用する**Convex**についても紹介しておきます。
世の中には`supabase`や`Firebase`などのBaaSがありますが、ConvexはそれらのBaaSとは違ったメリットがあります。
　

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/6c0448a4-1c5a-b5b3-e124-eb549b40a148.png)

とくにTypeScriptとの相性が良いのがConvexが最近好まれている理由だと思います。私自身も最近はConvexを利用することが増えました。

それでは解説は終わりましたのでノートアプリを開発していきましょう！

# 1. 環境構築

ここではViteを利用してReactの環境を作りスタイルのためTailwindCSSを導入します。

まずはNode環境があるかを確認します。

```
$ node -v
v22.4.0
```

ここでNode環境がない方は以下のサイトからインストールしてください。Node.jsのインストールはたくさん記事がありますので困ったら調べてください。

https://nodejs.org/ja

Reactのプロジェクトを構築します。
今回はViteを利用していきます。Viteは次世代のビルドツールで早くて無駄のない環境を提供してくれます。

https://ja.vitejs.dev/guide/

```
❯ npm create vite
Need to install the following packages:
  create-vite@5.5.2
Ok to proceed? (y) y
✔ Project name: … jotai-note-app
✔ Select a framework: › React
✔ Select a variant: › TypeScript

$ cd jotai-note-app/
$ npm i
$ npm run dev
```

[http://localhost:5173](http://localhost:5173)にアクセスして以下の画面が表示されればReact環境が無事できています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/cc74191c-f3a2-533b-93c4-97c5fb0a8d9b.png)


次にTailwindCSSを導入していきます。

```
$ npm install -D tailwindcss@3.4.13 postcss autoprefixer
$ npx tailwindcss init -p
```

**※ tailwindcssの最新4系をいれるとMDXEditorでスタイルが効かないので古いものを入れてください**

プロジェクトをVSCodeで開いて`tailwind.config.js`を以下に変えます

```js:tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
```

`src/index.css`を変更します

```css:src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

`src/App.tsx`を変更してスタイルがあたるかをチェックします

```tsx:src/App.tsx
function App() {
  return (
    <>
      <div>
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
          Button
        </button>
      </div>
    </>
  );
}

export default App;
```

一度サーバーを落として`npm run dev`で起動したらボタンが表示されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a844a950-e881-3dac-16cb-792dd2c4f430.png)


# 2. サイドメニューの開発

まずはサイドメニューの開発から始めていきます。
サイドメニューではそれぞれのノートの「タイトル」「更新日時」が表示されており、タイトルは変更することが可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d452f3ed-7474-86d7-b40e-a5e8d1a2b360.png)

ノートの削除をすることも可能です。
まずはこのアプリで利用するドメインから作成していきましょう

```
$ mkdir src/domain
$ touch src/domain/note.ts
```

```ts:src/domain/note.ts
export class Note {
  constructor(
    public id: string,
    public title: string,
    public content: string,
    public lastEditTime: number
  ) {}
}
```

ノートクラスを定義しました。
次にノートのダミーデータを使ってサイドメニューを作成します。

```
$ mkdir src/components
$ touch src/components/SideMenu.tsx
$ touch src/components/Editor.tsx
```

```tsx:src/components/SideMenu.tsx
function SideMenu() {
  return <div>SideMenu</div>;
}

export default SideMenu;
```

VSCodeであれば`rfcd`と打ってTabキーを押すと簡単に作成してくれます。

```tsx:src/components/Editor.tsx
function Editor() {
  return <div>Editor</div>;
}

export default Editor;
```

次に**Jotai**を使ってストアを用意してテストデータとを保存します。

```
$ npm i jotai
```

```
$ mkdir src/store
$ touch src/store/index.ts
```

```ts:src/store/index.ts
import { atom } from "jotai";
import { Note } from "../domain/note";

export const notesAtom = atom<Note[]>([]);
```

ノートの一覧を保存するためのストアを用意しました。
それでは実際にApp.tsxでダミーデータをストアに保存してみましょう

```tsx:src/App.tsx
import { useAtom } from "jotai";
import Editor from "./components/Editor";
import SideMenu from "./components/SideMenu";
import { notesAtom } from "./store";
import { useEffect } from "react";
import { Note } from "./domain/note";

function App() {
  const [notes, setNotes] = useAtom(notesAtom);

  useEffect(() => {
    const noteData = [
      new Note("1", "Note 1", "Content 1", new Date().getTime()),
      new Note("2", "Note 2", "Content 2", new Date().getTime()),
      new Note("3", "Note 3", "Content 3", new Date().getTime()),
    ];

    setNotes(noteData);
  }, [setNotes]);

  return (
    <>
      <div className="flex h-screen w-full bg-white">
        <SideMenu />
        <Editor />
      </div>
    </>
  );
}

export default App;
```

しかしこれだと`notes`が利用されておらず怒られてしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/b915a440-eebf-2e31-ecec-f56ae84cdf36.png)

ここでは`setNotes`だけ利用したいので`useAtom`ではなく`useSetAtom`を使うことができます。

```tsx
import { useSetAtom } from "jotai";
import Editor from "./components/Editor";
import SideMenu from "./components/SideMenu";
import { notesAtom } from "./store";
import { useEffect } from "react";

function App() {
  const setNotes = useSetAtom(notesAtom);
  const noteData = [
    {
      id: "1",
      title: "Note 1",
      content: "Content 1",
      lastEditTime: new Date().getTime(),
    },
    {
      id: "2",
      title: "Note 2",
      content: "Content 2",
      lastEditTime: new Date().getTime(),
    },
    {
      id: "3",
      title: "Note 3",
      content: "Content 3",
      lastEditTime: new Date().getTime(),
    },
  ];

  useEffect(() => {
    setNotes(noteData);
  }, [noteData]);

  return (
    <>
      <div className="flex h-screen w-full bg-white">
        <SideMenu />
        <Editor />
      </div>
    </>
  );
}

export default App;
```

`useEffect`(初回に実行される)の中でダミーデータをNoteクラスで作成してストアに保存しています。

```tsx
  useEffect(() => {
    setNotes(noteData);
  }, [noteData]
```

それでは次にサイドメニューで保存したノートを取得して表示してみましょう

```tsx:src/components/SideMenu.tsx
import { useAtomValue } from "jotai";
import { notesAtom } from "../store";

function SideMenu() {
  const notes = useAtomValue(notesAtom);

  return (
    <div className="w-64 h-screen bg-gray-100 p-4 flex flex-col">
      <div>
        <h2>Notes</h2>
        <button>+</button>
      </div>
      <div>
        {notes?.map((note) => (
          <div
            key={note.id}
            className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group"
          >
            <div className="flex-1 min-w-0">
              <input type="text" className="bg-gray-100" value={note.title} />
              <p>
                {note.lastEditTime
                  ? new Date(note.lastEditTime).toLocaleString()
                  : "Never edited"}
              </p>
            </div>
            <button>-</button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default SideMenu;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/b3602bbf-34a8-24c1-1ab3-0fd9b800667b.png)

ノートの取得は`useVaueAtom`を利用しています。これも先程と同じくセット関数(setNotes)を利用しないので値のみを取り出せる関数を使っています。

```tsx
  const notes = useAtomValue(notesAtom);
```

ノートはnotesを`map関数`を使ってそれぞれ表示しています。

```tsx
        {notes?.map((note) => (
          <div
            key={note.id}
            className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group"
          >
            <div className="flex-1 min-w-0">
              <input type="text" className="bg-gray-100" value={note.title} />
              <p>
                {note.lastEditTime
                  ? new Date(note.lastEditTime).toLocaleString()
                  : "Never edited"}
              </p>
            </div>
            <button>-</button>
          </div>
        ))}
```

ノートを1つ1つに書くとこのようになりますが、mapを使うことでスタイリッシュに書くことができます。

```tsx
<div>
  <div className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group">
    <div className="flex-1 min-w-0">
      <input type="text" className="bg-gray-100" value={notes[0].title} />
      <p>{notes[0].lastEditTime ? new Date(notes[0].lastEditTime).toLocaleString() : "Never edited"}</p>
    </div>
    <button>-</button>
  </div>

  <div className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group">
    <div className="flex-1 min-w-0">
      <input type="text" className="bg-gray-100" value={notes[1].title} />
      <p>{notes[1].lastEditTime ? new Date(notes[1].lastEditTime).toLocaleString() : "Never edited"}</p>
    </div>
    <button>-</button>
  </div>

  ノートの数だけ書くので大変！
  またノートが何個あるかを意識しないといけないので微妙
</div>
```

# 3. Convexからのデータ取得

ダミーデータを実際のDBのデータを取得して表示するように直しましょう
それにはConvexというBaaSを利用します。

アカウント登録をしていない人はアカウント登録をしてから続きを進めてください

https://www.convex.dev/

ConvexはTypeScriptで簡単に利用できるライブラリが提供されているのでインストールします。

```
$ npm i convex
```

プロジェクト作成も簡単にコマンドからできます

```
$ npx convex dev
? What would you like to configure? create a new project
? Project name: jotai-note-app
✔ Created project jotai-note-app, manage it at https://dashboard.convex.dev/t/watanabe-jin/jotai-note-app
✔ Provisioned a dev deployment and saved its:
    name as CONVEX_DEPLOYMENT to .env.local
    URL as VITE_CONVEX_URL to .env.local

Write your Convex functions in convex/
Give us feedback at https://convex.dev/community or support@convex.dev

✔ 14:54:23 Convex functions ready! (2.15s)
```

ここまで行えたら[Convex](https://dashboard.convex.dev/)をみると`jotai-note-app`というプロジェクトができているのでクリックしてください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/b3137049-842b-8e02-b864-eb37d6bae6a5.png)

またDBの接続情報も勝手に`.env.local`で作成してくれています。
`convex`というディレクトリも作成してくれています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/3b1b103e-4c8a-99af-3383-62257d77f8e2.png)

続いて今回利用するDBのスキーマを作成していきます。
Convexの便利なところは`convex/schema.ts`にテーブルスキーマを書くと`npx convex dev`が起動している間自動で変更を検知してDBに変更を反映してくれます(マイグレーションともいいます)

```
$ touch convex/schema.ts
```

```ts:convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  notes: defineTable({
    title: v.string(),
    content: v.string(),
    lastEditTime: v.number(),
  }),
});
```

ノートは名前、内容、最終編集日を持つようにしました。
またidに関してはConvexが自動で付与してくれるので書く必要はありません

ここで`npx convex dev`が実行されている状態であれば変更が反映されているのでDBをみてみます。
左メニューから「Data」をクリックすると`notes`というテーブルができています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/af7f4f18-9aec-4109-1b04-0095de8ec06a.png)

それではこちらにテストデータを3つ作りましょう
「Add Documents」をクリックして

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/1febe408-06ab-b30a-39b2-ff9bceaa3a10.png)

content: `# 新しいノート`
title: ノート1

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ca2a501a-4929-4dba-d82f-5f5b3ee9b8e7.png)

入力したら「Save」を押して保存します。
同じ要領で残りのデータも追加してください。

データ2: content: `**Content**` title: ノート2
データ3: content: hello title: ノート3

lastEditTimeは何も入れなくて大丈夫です。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/97284a89-fc1c-9c03-38b3-320581de8d46.png)

ダミーデータが追加できたので、ReactにConvexの設定をしていきます。
基本的にはドキュメントどおりになっています。

https://docs.convex.dev/client/react

`main.tsx`を修正します。

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

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

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ConvexProvider client={convex}>
      <div className="flex h-screen w-screen">
        <App />
      </div>
    </ConvexProvider>
  </StrictMode>
);
```

これでConvexの必要な設定は完了したのでDBからノート一覧を取得する処理を書いていきます。

```
$ touch convex/notes.ts
```

この`notes.ts`にノート一覧取得や新規ノート作成の処理を書いて、コンポーネントで呼び出すことになります。

```ts:convex/notes.ts
import { query } from "./_generated/server";

export const get = query({
  args: {},
  handler: async (ctx) => {
    const notesJson = await ctx.db.query("notes").collect();
    return notesJson
  },
});
```

Convexでは`query`と`mutation`という関数を利用します。
GETメソッドがquery、POST,PUT,PATCH,DELETEはmutationを使います。

最初に`get`メソッドの引数を`args`で書いています。今回は必要ないので空のオブジェクトですが、例えば特定のノートを取得する場合は`id`などを受け取ります。

次に`get`が呼び出されたときの挙動を`handler`に書きます。

```ts
handler: async (ctx) => {

    });
```

`ctx`はコンテキストと呼ばれるもので、Convexの初期設定で`client`に追加したConvexのクライアント(Convexとやり取りするためもの)です。

```ts
    <ConvexProvider client={convex}>
      <div className="flex h-screen w-screen">
        <App />
      </div>
    </ConvexProvider>
```

ctxをつけているために`ConvexProvider`を先程設定しました。
Convexクライアント(ctx)を使うことで簡単にデータベースから情報を取得できます。

```ts
const notesJson = await ctx.db.query("notes").collect();
return notesJson
```

そしてJosnでデータを返しています。
`get`を呼び出すと最終的には

```json
[
 {
   "_id": "xxx",
   "title": "title1", 
   "content": "content1",
   "lastEditTime": "yyyy"
 },
 ....
 ,
 {
   "_id": "zzzz",
   "title": "title3",
   "content": "content3", 
   "lastEditTime": "yyyyy"
 }
]
```

このようなjsonの配列が返ってきます。
それでは実際にアプリケーションが開くタイミングでデータを取得してストアに保存してみましょう

```tsx:src/App.tsx
import { useSetAtom } from "jotai";
import Editor from "./components/Editor";
import SideMenu from "./components/SideMenu";
import { notesAtom } from "./store";
import { useEffect } from "react";
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Note } from "./domain/note";

function App() {
  const setNotes = useSetAtom(notesAtom);
  const initializeNotes = useQuery(api.notes.get); // 追加

  useEffect(() => {
    const notes = initializeNotes?.map(
      (note) => new Note(note._id, note.title, note.content, note.lastEditTime)
    );
    setNotes(notes || []);
  }, [setNotes, initializeNotes]); // 修正

  return (
    <>
      <div className="flex h-screen w-full bg-white">
        <SideMenu />
        <Editor />
      </div>
    </>
  );
}

export default App;
```

まずはクライアント側で`useQuery`を使って先程作成した`get`を呼び出します。

```tsx
const initializeNotes = useQuery(api.notes.get);
```

`npm convex dev`をしていると型情報が自動で生成されるためinitializeNotesにホバーをすると型情報が見れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/88092b5c-70d3-00ca-e154-b6df60a5ad3e.png)

`useEffect`では取得したデータをNoteクラスに変換してNoteクラスの配列を保存しています。

```tsx
  useEffect(() => {
    const notes = initializeNotes?.map(
      (note) => new Note(note._id, note.title, note.content, note.lastEditTime)
    );
    setNotes(notes || []);
  }, [setNotes, initializeNotes]);
```

Noteクラスにしないといけないのは`notesAtom`の型を`Note[]`と定義したからです。

```ts
export const notesAtom = atom<Note[]>([]);
```

それでは実際にアプリを見てみましょう
convexの起動で`npm run dev`を消してしまった人は再度サーバー起動してください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/cf731750-7395-2c49-34f6-547103f020bb.png)

表示されました！
日付が`Never edited`になっているのはDBの日付を0で保存しているからです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/929d69f9-f549-e308-c851-bc37bce3fae8.png)


このあと修正すれば時間は表示されるので先に進みましょう

# 4. ノートの新規作成と削除

続いてサイドメニューの「+」なら新規作成、「-」ならノート削除する機能を実装しましょう

```ts:convex/notes.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";

export const get = query({
  args: {},
  handler: async (ctx) => {
    const notesJson = await ctx.db.query("notes").collect();
    return notesJson

  },
});

// 追加
export const create = mutation({
  args: {
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const noteId = await ctx.db.insert("notes", {
      title: args.title,
      content: args.content,
      lastEditTime: Date.now(),
    });
    return noteId;
  },
});
```

まずはConvexにデータを追加する`create`を作成しました

```ts
  args: {
    title: v.string(),
    content: v.string(),
  },
```

タイトルと内容を受け取ってインサートをしています。

```tsx:src/components/SideMenu.tsx
import { useAtom } from "jotai";
import { notesAtom } from "../store";
import { Note } from "../domain/note";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";

function SideMenu() {
  const [notes, setNotes] = useAtom(notesAtom); // 修正
  const createNote = useMutation(api.notes.create); // 追加

  // 追加
  const handleCreateNote = async () => {
    const noteId = await createNote({
      title: "Untitled",
      content: "",
    });

    const newNote = new Note(noteId, "Untitled", "", Date.now());
    setNotes((prev) => [...prev, newNote]);
  };

  return (
    <div className="w-64 h-screen bg-gray-100 p-4 flex flex-col">
      <div>
        <h2>Notes</h2>
        {/* 修正 */}
        <button onClick={handleCreateNote}>+</button>
      </div>
      <div>
        {notes?.map((note) => (
          <div
            key={note.id}
            className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group"
          >
            <div className="flex-1 min-w-0">
              <input type="text" className="bg-gray-100" value={note.title} />
              <p>
                {note.lastEditTime
                  ? new Date(note.lastEditTime).toLocaleString()
                  : "Never edited"}
              </p>
            </div>
            <button>-</button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default SideMenu;
```

まずはノートの新規作成からです。
+ボタンを押したら`handleCreateNote`が実行されるように設定します。

```tsx
<button onClick={handleCreateNote}>+</button>
```

handleCreateNoteではまずConvexの`create`を呼び出してノートをDBにインサートしています。タイトルは`Untitled`で内容は空で追加します。

```tsx
  const handleCreateNote = async () => {
    const noteId = await createNote({
      title: "Untitled",
      content: "",
    });
```

そのあとに`notesAtom`に新しいノートを追加した`notes`で更新をします。

```tsx
    const newNote = new Note(noteId, "Untitled", "", Date.now());
    setNotes((prev) => [...prev, newNote]); // ...prevとすることで現在のnotesを展開してその後ろにnewNoteを追加
```

ここで`notes`と`setNotes`を使うことになったので`useValueAtom`から`useAtom`に変更しました。

```ts
const [notes, setNotes] = useAtom(notesAtom);
```

実際に画面で「+」を押すと新規ノートが追加されます。
リロードしても表示されるのでDBにも保存されていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/505b7822-fa76-a618-c3c1-02490a7e20b7.png)

新規追加は`lastEditTime`に`new Date()`を追加しているので最終編集日もちゃんと表示されていますね。

同じ要領で削除機能も実装しましょう。

```tsx:convex/notes.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";

export const get = query({
  args: {},
  handler: async (ctx) => {
    const notesJson = await ctx.db.query("notes").collect();
    return notesJson
  },
});

export const create = mutation({
  args: {
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const noteId = await ctx.db.insert("notes", {
      title: args.title,
      content: args.content,
      lastEditTime: Date.now(),
    });
    return noteId;
  },
});

// 追加
export const deleteNote = mutation({
  args: {
    noteId: v.id("notes"),
  },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.noteId);
  },
});
```

`deleteNote`はノートのidを受け取りますが、

```ts
  args: {
    noteId: v.id("notes"),
  },
```

としています。これはConvexが用意しているidの型であることを意味しています。
もしこれを`v.string()`にすると以下のようなエラーになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/0c689b65-6788-75db-5227-1dc52edfbb20.png)

これはConvexがDBの型を把握しており、DBではIdはId<notes>という特別な型になっているのでstringと違うのでエラーを出しています。
なので、`v.id(notes)`として受け取るようにしました。

```tsx:src/components/SideMenu.tsx
import { useAtom } from "jotai";
import { notesAtom } from "../store";
import { Note } from "../domain/note";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";

function SideMenu() {
  const [notes, setNotes] = useAtom(notesAtom);
  const createNote = useMutation(api.notes.create);
  const deleteNote = useMutation(api.notes.deleteNote); // 追加

  const handleCreateNote = async () => {
    const noteId = await createNote({
      title: "Untitled",
      content: "",
    });

    const newNote = new Note(noteId, "Untitled", "", Date.now());
    setNotes((prev) => [...prev, newNote]);
  };

  // 追加
  const handleDeleteNote = async (noteId: Id<"notes">) => {
    await deleteNote({ noteId });
    setNotes((prev) => prev.filter((n) => n.id !== noteId));
  };

  return (
    <div className="w-64 h-screen bg-gray-100 p-4 flex flex-col">
      <div>
        <h2>Notes</h2>
        <button onClick={handleCreateNote}>+</button>
      </div>
      <div>
        {notes?.map((note) => (
          <div
            key={note.id}
            className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group"
          >
            <div className="flex-1 min-w-0">
              <input type="text" className="bg-gray-100" value={note.title} />
              <p>
                {note.lastEditTime
                  ? new Date(note.lastEditTime).toLocaleString()
                  : "Never edited"}
              </p>
            </div>
            {/* 修正 */}
            <button onClick={() => handleDeleteNote(note.id)}>-</button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default SideMenu;
```

新規作成とほとんど実装は変わらないのですが、これではエラーが出ます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/9d77a238-7e4f-4505-6739-32ec5256ff46.png)

いまNoteクラスの`id`は`string`になっているのでhanldeDeleteNoteでは受け取れないです。

```ts
const handleDeleteNote = async (noteId: Id<"notes">) => {
```

handleDeleteNoteで受け取る`noteId`はConvexのidの型である`Id<"notes">`としています。これはapi.notes.deleteを呼ぶためです。

```ts
export const deleteNote = mutation({
  args: {
    noteId: v.id("notes"),
  },
```

となるとNoteクラスの型を直す必要が出てきました。

```tsx:src/domain/note.ts
import { Id } from "../../convex/_generated/dataModel";

export class Note {
  constructor(
    public id: Id<"notes">, // 修正
    public title: string,
    public content: string,
    public lastEditTime: number
  ) {}
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/80d7c82f-231f-5a1a-89f1-cf59045a0a33.png)

最初に作成したダミーデータが削除できました！

# 5. 選択したノートの内容を表示する

次に選択したノートの内容を`Editor.tsx`で表示する実装をします。
ノートを選択したらストアに選択したノートを保存しておき、Editor.tsxでノートを取得して表示するように実装しましょう

```ts:src/store/index.ts
import { atom } from "jotai";
import { Note } from "../domain/note";
import { Id } from "../../convex/_generated/dataModel";

export const notesAtom = atom<Note[]>([]);
export const selectedNoteIdAtom = atom<Id<"notes"> | null>(null); // 追加

export const selectedNoteAtom = atom((get) => {
  const notes = get(notesAtom);
  const id = get(selectedNoteIdAtom);
  if (id === null) return null;

  return notes.find((note) => note.id === id) || null;
}); // 追加
```

選択しているノートのIDを保存しておくAtom(selectedNoteIdAtom)を作成しました。

選択されているノートをIDから検索してくれるAtomがあると便利なので、**派生Atom**の`selectedNoteAtom`も作ります。これは読み込み専用Atomで、ストアの`selectedNoteIdAtom`の情報を使って選択されたノートを検索してくれます。

```ts
  return notes.find((note) => note.id === id) || null;
```

次にSideMenuでノートを選択したら`selectedNoteIdAtom`を更新する処理を追加します。

```tsx:src/components/SideMenu.tsx
import { useAtom, useSetAtom } from "jotai";
import { notesAtom, selectedNoteIdAtom } from "../store";
import { Note } from "../domain/note";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";

function SideMenu() {
  const [notes, setNotes] = useAtom(notesAtom);
  const setSelectedNoteId = useSetAtom(selectedNoteIdAtom); // 追加
  const createNote = useMutation(api.notes.create);
  const deleteNote = useMutation(api.notes.deleteNote);

  const handleCreateNote = async () => {
    const noteId = await createNote({
      title: "Untitled",
      content: "",
    });

    const newNote = new Note(noteId, "Untitled", "", Date.now());
    setNotes((prev) => [...prev, newNote]);
  };

  const handleDeleteNote = async (noteId: Id<"notes">) => {
    await deleteNote({ noteId });
    setNotes((prev) => prev.filter((n) => n.id !== noteId));
  };

  // 追加
  const handleNoteClick = (note: Note) => {
    setSelectedNoteId(note.id);
  };

  return (
    <div className="w-64 h-screen bg-gray-100 p-4 flex flex-col">
      <div>
        <h2>Notes</h2>
        <button onClick={handleCreateNote}>+</button>
      </div>
      <div>
        {notes?.map((note) => (
          <div
            key={note.id}
            className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group"
            onClick={() => handleNoteClick(note)} // 追加
          >
            <div className="flex-1 min-w-0">
              <input type="text" className="bg-gray-100" value={note.title} />
              <p>
                {note.lastEditTime
                  ? new Date(note.lastEditTime).toLocaleString()
                  : "Never edited"}
              </p>
            </div>
            <button onClick={() => handleDeleteNote(note.id)}>-</button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default SideMenu;
```

そしてEditor.tsxでは選択されているノートの内容を表示させます。

```tsx:src/components/Editor.tsx
import { useAtomValue } from "jotai";
import { selectedNoteAtom } from "../store";

function Editor() {
  const selectedNote = useAtomValue(selectedNoteAtom);

  return <div>{selectedNote?.content || ""}</div>;
}

export default Editor;
```

確認するときはconvexで`content`のデータを直接入れてあげてからやります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/fb90f3e8-d9fa-7333-eed8-a5e887bc99f4.png)

問題なく表示できました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d9fefb12-3e7e-e4e1-866c-863f6ff92ca9.png)

残りサイドメニューで必要な機能がタイトル変更なので、そちらも実装しちゃいます。
まずはDBのノートを更新するための関数を作成します。

```ts:convex/notes.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";

export const get = query({
  args: {},
  handler: async (ctx) => {
    const notesJson = await ctx.db.query("notes").collect();
    return notesJson
  },
});

export const create = mutation({
  args: {
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const noteId = await ctx.db.insert("notes", {
      title: args.title,
      content: args.content,
      lastEditTime: Date.now(),
    });
    return noteId;
  },
});

export const deleteNote = mutation({
  args: {
    noteId: v.id("notes"),
  },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.noteId);
  },
});

// 追加
export const updateNote = mutation({
  args: {
    noteId: v.id("notes"),
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.noteId, {
      title: args.title,
      content: args.content,
      lastEditTime: Date.now(),
    });
  },
});
```

引数としてノートIDとタイトルと内容を受け取れるようにしています。
これはこのあとエディタで内容を編集したときにも同じ関数が使えるようにです。
しかし、今回はタイトルしか変わる箇所はありません。

またupdateをするときは`lastEditTime`を現在の時刻に更新しています。

```tsx
    await ctx.db.patch(args.noteId, {
      title: args.title,
      content: args.content,
      lastEditTime: Date.now(),
    });
```

サイドメニューはすこし複雑になっているので細かく解説していきます。
タイトルは変更1文字ごとにDBに更新をかけてしまうとフォーム入力が1文字ごとに固まってしまうので**デバウンス**を使います。

デバウンスを使うことで入力が終わってからDBへの保存をすることができます。
ライブラリをインストールすれば簡単に実現できます。

``` 
$ npm i @uidotdev/usehooks
```

```tsx:src/components/SideMenu.tsx
import { useAtom, useSetAtom } from "jotai";
import { notesAtom, selectedNoteIdAtom } from "../store";
import { Note } from "../domain/note";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import { useEffect, useState } from "react";
import { useDebounce } from "@uidotdev/usehooks";

function SideMenu() {
  const [notes, setNotes] = useAtom(notesAtom);
  const setSelectedNoteId = useSetAtom(selectedNoteIdAtom);
  const createNote = useMutation(api.notes.create);
  const deleteNote = useMutation(api.notes.deleteNote);
  const updateNote = useMutation(api.notes.updateNote); // 追加
  const [editingTitle, setEditingTitle] = useState<{
    id: Id<"notes">;
    title: string;
  } | null>(null); // 追加

  const handleCreateNote = async () => {
    const noteId = await createNote({
      title: "Untitled",
      content: "",
    });

    const newNote = new Note(noteId, "Untitled", "", Date.now());
    setNotes((prev) => [...prev, newNote]);
  };

  const handleDeleteNote = async (noteId: Id<"notes">) => {
    await deleteNote({ noteId });
    setNotes((prev) => prev.filter((n) => n.id !== noteId));
  };

  const handleNoteClick = (note: Note) => {
    setSelectedNoteId(note.id);
  };

  // 追加
  const handleTitleChange = (noteId: Id<"notes">, newTitle: string) => {
    setEditingTitle({ id: noteId, title: newTitle });
    setNotes((prev) =>
      prev.map((n) => (n.id === noteId ? { ...n, title: newTitle } : n))
    );
  };

  // 追加
  const debouncedTitle = useDebounce(editingTitle?.title, 500);
  useEffect(() => {
    if (editingTitle && debouncedTitle) {
      handleUpdateTitle(editingTitle.id, debouncedTitle);
    }
  }, [debouncedTitle]);

　// 追加
  const handleUpdateTitle = async (noteId: Id<"notes">, newTitle: string) => {
    const note = notes.find((n) => n.id === noteId);
    if (!note) return;

    await updateNote({
      noteId,
      title: newTitle,
      content: note.content,
    });
  };

  return (
    <div className="w-64 h-screen bg-gray-100 p-4 flex flex-col">
      <div>
        <h2>Notes</h2>
        <button onClick={handleCreateNote}>+</button>
      </div>
      <div>
        {notes?.map((note) => (
          <div
            key={note.id}
            className="p-2 mb-2 rounded cursor-pointer flex justify-between items-center group"
            onClick={() => handleNoteClick(note)}
          >
            <div className="flex-1 min-w-0">
              <input
                type="text"
                className="bg-gray-100"
                onChange={(e) => handleTitleChange(note.id, e.target.value)} // 修正
                value={note.title}
              />
              <p>
                {note.lastEditTime
                  ? new Date(note.lastEditTime).toLocaleString()
                  : "Never edited"}
              </p>
            </div>
            <button onClick={() => handleDeleteNote(note.id)}>-</button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default SideMenu;
```

まずはタイトルを入力するフォームのステートを保存しておくためuseStateを使います。

```ts
  const [editingTitle, setEditingTitle] = useState<{
    id: Id<"notes">;
    title: string;
  } | null>(null);
```

またデバウンスの設定もしておきます。

```ts
  const debouncedTitle = useDebounce(editingTitle?.title, 500);
  useEffect(() => {
    if (editingTitle && debouncedTitle) {
      handleUpdateTitle(editingTitle.id, debouncedTitle);
    }
  }, [debouncedTitle]);
```

文字を変更してから0.5秒間変更がなかったら(つまり手が止まったら)、useEffectを実行するという設定になっています。useEffectの中では`handleUpdateTitle`を実行しています。

```ts
  const handleUpdateTitle = async (noteId: Id<"notes">, newTitle: string) => {
    const note = notes.find((n) => n.id === noteId);
    if (!note) return;

    await updateNote({
      noteId,
      title: newTitle,
      content: note.content,
    });
  };
  ```

  hanldeUpdateTitleではタイトル変更されたノートのIdと新しいタイトルを受け取っています。notesの中から編集しているノートをみつけて`updateNote`でDBを更新しています。

```ts
const updateNote = useMutation(api.notes.updateNote); 

(省略)

  const note = notes.find((n) => n.id === noteId);
(省略)
  await updateNote({
      noteId,
      title: newTitle,
      content: note.content,
    });
```

タイトルを変更することができるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/dc3efe98-04ce-d1a7-8f8a-77522cd9a61c.png)

# 6. エディタを実装する

次はエディタを実装して入力した内容を保存できるようにしましょう。
今回は`MDXEditor`というライブラリを利用して簡単にマークダウンが書けるエディタを実装しましょう

https://mdxeditor.dev/

```
$ npm install --save @mdxeditor/editor
$ npm i -D @tailwindcss/typography
```

次にMDXEditorでh1を利用できるようにするために設定をしておきます。

```js:tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")], // 修正
};
```

それではエディタを実装しましょう

```tsx:src/components/Editor.tsx
import { useAtomValue } from "jotai";
import { selectedNoteAtom } from "../store";
import "@mdxeditor/editor/style.css";
import {
  MDXEditor,
  headingsPlugin,
  listsPlugin,
  markdownShortcutPlugin,
  type MDXEditorMethods,
  toolbarPlugin,
  BoldItalicUnderlineToggles,
  ListsToggle,
  codeBlockPlugin,
  codeMirrorPlugin,
  InsertCodeBlock,
} from "@mdxeditor/editor";
import { useRef } from "react";

const plugins = [
  headingsPlugin(),
  listsPlugin(),
  markdownShortcutPlugin(),
  codeBlockPlugin({
    defaultCodeBlockLanguage: "js",
  }),
  codeMirrorPlugin({
    codeBlockLanguages: {
      js: "JavaScript",
      jsx: "JavaScript JSX",
      ts: "TypeScript",
      tsx: "TypeScript JSX",
      python: "Python",
      css: "CSS",
      html: "HTML",
      json: "JSON",
    },
  }),
  toolbarPlugin({
    toolbarContents: () => (
      <>
        <div className="flex items-center gap-1">
          <div className="flex gap-1">
            <BoldItalicUnderlineToggles />
          </div>
          <div className="flex gap-1">
            <ListsToggle />
          </div>
          <InsertCodeBlock />
        </div>
      </>
    ),
  }),
];

export const Editor = () => {
  const selectedNote = useAtomValue(selectedNoteAtom);

  return (
    <div className="flex-1">
      {selectedNote ? (
        <MDXEditor
          key={selectedNote.id}
          markdown={selectedNote.content}
          plugins={plugins}
          contentEditableClassName="prose max-w-none focus:outline-none"
          className="h-full"
          placeholder="Markdownを入力してください"
        />
      ) : (
        <div className="h-screen flex items-center justify-center">
          <p className="text-gray-500">
            ノートを選択するか、新しいノートを作成してください
          </p>
        </div>
      )}
    </div>
  );
};

export default Editor;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/7d908e47-d51b-cbf3-4836-5ee2222db08a.png)

ノートは選択されていないとselectedNoteがundefinedになるため選択されていない時(初回)は文言を出すようにしました

```tsx
      {selectedNote ? (
        // エディターの実装
      ) : (
        <div className="h-screen flex items-center justify-center">
          <p className="text-gray-500">
            ノートを選択するか、新しいノートを作成してください
          </p>
        </div>
      )}
```

エディタは用意してくれているプラグインを利用することでリッチな機能を簡単に利用することができます。

```tsx
const plugins = [
  headingsPlugin(),
  listsPlugin(),
  markdownShortcutPlugin(),
  codeBlockPlugin({
    defaultCodeBlockLanguage: "js",
  }),
  codeMirrorPlugin({
    codeBlockLanguages: {
      js: "JavaScript",
      jsx: "JavaScript JSX",
      ts: "TypeScript",
      tsx: "TypeScript JSX",
      python: "Python",
      css: "CSS",
      html: "HTML",
      json: "JSON",
    },
  }),
  toolbarPlugin({
    toolbarContents: () => (
      <>
        <div className="flex items-center gap-1">
          <div className="flex gap-1">
            <BoldItalicUnderlineToggles />
          </div>
          <div className="flex gap-1">
            <ListsToggle />
          </div>
          <InsertCodeBlock />
        </div>
      </>
    ),
  }),
];

(省略)

        <MDXEditor
          key={selectedNote.id}
          markdown={selectedNote.content}
          plugins={plugins}
          contentEditableClassName="prose max-w-none focus:outline-none"
          className="h-full"
          placeholder="Markdownを入力してください"
        />
```

プラグインの細かい解説は本質と外れるので公式ドキュメントをみてください。
それではノートを書いたらデバウンスを使ってノートを更新する処理を追加しましょう

```tsx:src/store/index.ts
import { atom } from "jotai";
import { Note } from "../domain/note";
import { Id } from "../../convex/_generated/dataModel";

export const notesAtom = atom<Note[]>([]);
export const selectedNoteIdAtom = atom<Id<"notes"> | null>(null);

export const selectedNoteAtom = atom((get) => {
  const notes = get(notesAtom);
  const id = get(selectedNoteIdAtom);
  if (id === null) return null;

  return notes.find((note) => note.id === id) || null;
});

// 追加
export const saveNoteAtom = atom(null, (get, set, newContent: string) => {
  const note = get(selectedNoteAtom);
  if (!note) return;

  const updatedNote = new Note(note.id, note.title, newContent, Date.now());
  const notes = get(notesAtom);
  const updatedNotes = notes.map((n) => {
    if (n.id !== note.id) return n;
    return updatedNote;
  });

  set(notesAtom, updatedNotes);
});
```

saveAtomという書き込可能な派生Atomを作りました。
最初に現在の選択されているノートを取得して、更新されたノートを作成します

```tsx
const note = get(selectedNoteAtom);
if (!note) return;
  
const updatedNote = new Note(note.id, note.title, newContent, Date.now());
```

そのあと現在のnotesをストアから取り出してその中から該当のノートを見つけて内容を更新してあげています。

```ts
  const notes = get(notesAtom);
  const updatedNotes = notes.map((n) => {
    if (n.id !== note.id) return n;
    return updatedNote;
  });
```

`set`の第一引数に更新対象のAtom、第二引数に更新する値を渡して更新完了です。

```ts
  set(notesAtom, updatedNotes);
```

`saveNoteAtom`を利用すればnotesを更新することができます。(実際に更新されるのはnotesの中の編集されたノートのみです)

```tsx:src/components/Editor.tsx
import { useAtomValue, useSetAtom } from "jotai";
import { saveNoteAtom, selectedNoteAtom } from "../store";
import "@mdxeditor/editor/style.css";
import {
  MDXEditor,
  headingsPlugin,
  listsPlugin,
  markdownShortcutPlugin,
  toolbarPlugin,
  BoldItalicUnderlineToggles,
  ListsToggle,
  codeBlockPlugin,
  codeMirrorPlugin,
  InsertCodeBlock,
} from "@mdxeditor/editor";
import { useCallback, useEffect, useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useDebounce } from "@uidotdev/usehooks";

const plugins = [
  headingsPlugin(),
  listsPlugin(),
  markdownShortcutPlugin(),
  codeBlockPlugin({
    defaultCodeBlockLanguage: "js",
  }),
  codeMirrorPlugin({
    codeBlockLanguages: {
      js: "JavaScript",
      jsx: "JavaScript JSX",
      ts: "TypeScript",
      tsx: "TypeScript JSX",
      python: "Python",
      css: "CSS",
      html: "HTML",
      json: "JSON",
    },
  }),
  toolbarPlugin({
    toolbarContents: () => (
      <>
        <div className="flex items-center gap-1">
          <div className="flex gap-1">
            <BoldItalicUnderlineToggles />
          </div>
          <div className="flex gap-1">
            <ListsToggle />
          </div>
          <InsertCodeBlock />
        </div>
      </>
    ),
  }),
];

export const Editor = () => {
  const selectedNote = useAtomValue(selectedNoteAtom);
  // 追加
  const updateNote = useMutation(api.notes.updateNote);
  // 追加
  const saveNote = useSetAtom(saveNoteAtom);
  // 追加
  const [content, setContent] = useState<string>("");

  const debouncedContent = useDebounce(content, 1000); // 追加
  useEffect(() => {
    if (!selectedNote || !debouncedContent) return;
    updateNote({
      noteId: selectedNote.id,
      content: debouncedContent,
      title: selectedNote.title,
    });
  }, [debouncedContent, selectedNote, updateNote]);

　// 追加
  const handleContentChange = useCallback(
    (newContent: string) => {
      setContent(newContent);
      saveNote(newContent);
    },
    [saveNote]
  );

  return (
    <div className="flex-1">
      {selectedNote ? (
        <MDXEditor
          key={selectedNote.id}
          markdown={selectedNote.content}
          onChange={handleContentChange} // 追加
          plugins={plugins}
          contentEditableClassName="prose max-w-none focus:outline-none"
          className="h-full"
          placeholder="Markdownを入力してください"
        />
      ) : (
        <div className="h-screen flex items-center justify-center">
          <p className="text-gray-500">
            ノートを選択するか、新しいノートを作成してください
          </p>
        </div>
      )}
    </div>
  );
};

export default Editor;
```

まずはエディタの入力内容を保存しておくステートを用意します。
ノートの内容を更新すると`handleContentChange`が実行されてステートの値とnotesが更新されます。

```tsx
  const [content, setContent] = useState<string>("");

省略
  
  const handleContentChange = useCallback(
    (newContent: string) => {
      setContent(newContent);
      saveNote(newContent);
    },
    [saveNote]
  );

省略
        <MDXEditor
          key={selectedNote.id}
          markdown={selectedNote.content}
          onChange={handleContentChange} // 追加
          plugins={plugins}
          contentEditableClassName="prose max-w-none focus:outline-none"
          className="h-full"
          placeholder="Markdownを入力してください"
        />
```

ここでもデバウンスを利用しており、ノートの入力開始から1秒間入力が行われなかったらノートの内容をDBに更新しています。

```tsx
  const debouncedContent = useDebounce(content, 1000); // 追加
  useEffect(() => {
    if (!selectedNote || !debouncedContent) return;
    updateNote({
      noteId: selectedNote.id,
      content: debouncedContent,
      title: selectedNote.title,
    });
  }, [debouncedContent, selectedNote, updateNote]);
```

ここで`useCallback`という普段あまりみかけないHookがでてきたので解説します。
　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/0035b779-1bef-0563-2631-ad506385aac6.png)

useCallbackは関数をキャッシュするための関数です。
今回`handleContentChange`をMDXEditorにPropsとして渡しています。
このようなときにuseCallbackは有効です。

もし何らかの理由でEditor.tsxの再レンダリングが走ったとしても、handleContentChangeはキャッシュされているためMDXEditorの再レンダリングを防ぐことが可能です。

Propsに関数を渡す場合はキャッシュしておくのがよいでしょう(ただしコンポーネント側でメモ化されている必要があります)

# 7. デザインを整える

ここまでで必要な機能はすべてできたのであとはデザインを整えていきます。
エディタはスタイルも一緒に行っているのでサイドメニューのスタイルをあてます。

まずはアイコンを使えるようにするライブラリをインストールします

```
$ npm i lucide-react
```

```tsx:src/components/SideMenu.tsx
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { notesAtom, selectedNoteIdAtom } from "../store";
import { Note } from "../domain/note";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import { useEffect, useState } from "react";
import { useDebounce } from "@uidotdev/usehooks";
import { Plus, Trash2 } from "lucide-react";

function SideMenu() {
  const [notes, setNotes] = useAtom(notesAtom);
  const setSelectedNoteId = useSetAtom(selectedNoteIdAtom);
  const createNote = useMutation(api.notes.create);
  const deleteNote = useMutation(api.notes.deleteNote);
  const updateNote = useMutation(api.notes.updateNote);
  const selectedNoteId = useAtomValue(selectedNoteIdAtom); // 追加
  const [editingTitle, setEditingTitle] = useState<{
    id: Id<"notes">;
    title: string;
  } | null>(null);

  const handleCreateNote = async () => {
    const noteId = await createNote({
      title: "Untitled",
      content: "",
    });

    const newNote = new Note(noteId, "Untitled", "", Date.now());
    setNotes((prev) => [...prev, newNote]);
  };

  const handleDeleteNote = async (noteId: Id<"notes">) => {
    await deleteNote({ noteId });
    setNotes((prev) => prev.filter((n) => n.id !== noteId));
  };

  const handleNoteClick = (note: Note) => {
    setSelectedNoteId(note.id);
  };

  const handleTitleChange = (noteId: Id<"notes">, newTitle: string) => {
    setEditingTitle({ id: noteId, title: newTitle });
    setNotes((prev) =>
      prev.map((n) => (n.id === noteId ? { ...n, title: newTitle } : n))
    );
  };

  const debouncedTitle = useDebounce(editingTitle?.title, 500);
  useEffect(() => {
    if (editingTitle && debouncedTitle) {
      handleUpdateTitle(editingTitle.id, debouncedTitle);
    }
  }, [debouncedTitle]);

  const handleUpdateTitle = async (noteId: Id<"notes">, newTitle: string) => {
    const note = notes.find((n) => n.id === noteId);
    if (!note) return;

    await updateNote({
      noteId,
      title: newTitle,
      content: note.content,
    });
  };

  return (
    <div className="w-64 h-screen bg-gray-100 p-4 flex flex-col">
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-xl font-bold">Notes</h2>
        <button
          onClick={handleCreateNote}
          className="p-2 bg-white rounded hover:bg-gray-50"
        >
          <Plus className="h-4 w-4" />
        </button>
      </div>
      <div className="flex-grow overflow-y-auto">
        {notes?.map((note) => (
          <div
            key={note.id}
            className={`p-2 mb-2 rounded cursor-pointer flex justify-between items-center group ${
              selectedNoteId === note.id ? "bg-white" : "hover:bg-white"
            }`}
            onClick={() => handleNoteClick(note)}
          >
            <div className="flex-1 min-w-0">
              <input
                type="text"
                value={note.title}
                onChange={(e) => handleTitleChange(note.id, e.target.value)}
                onClick={(e) => e.stopPropagation()}
                className="font-medium bg-transparent outline-none w-full"
                placeholder="Untitled"
              />
              <p className="text-xs text-gray-500 truncate">
                {note.lastEditTime
                  ? new Date(note.lastEditTime).toLocaleString()
                  : "Never edited"}
              </p>
            </div>
            <button
              onClick={(e) => {
                e.stopPropagation();
                handleDeleteNote(note.id);
              }}
              className="text-gray-400 hover:text-red-500 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity ml-2"
            >
              <Trash2 className="h-4 w-4" />
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default SideMenu;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/1eb09e22-b338-4a7d-051d-eafb1732ee2e.png)

ノートアプリが完成しました！お疲れ様です！

# おわりに

いかがでしたでしょうか？
今回はJotaiを活用してノートアプリを作りました！
状態管理ライブラリを使うことで簡単に機能が実現できるのでぜひ利用してください！

テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください！

<iframe width="560" height="315" src="https://www.youtube.com/embed/4NOy1XmICkg?si=66GgEGO8QLW2RoVh" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://github.com/jinwatanabe/jotai-note-app

# JISOUのメンバー募集中！
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか？
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください！
▼▼▼

https://projisou.jp

# 本記事のレビュアーの皆様
tokec様
吉田侑平様
ARISA様
上嶋晃太様
たけしよしき様

次回のハンズオンのレビュアーは[X](https://x.com/Sicut_study)にて募集します。


# 図解ハンズオンたくさん投稿しています！

https://qiita.com/Sicut_study/items/c12faaf962646bee34f7

https://qiita.com/Sicut_study/items/a27fb53468e14216d6fb

https://qiita.com/Sicut_study/items/0318cc136c189b179b7f