Electron + Reactでデスクトップアプリを作ろう！

## Electronとは？

Electronは、HTML、CSS、JavaScriptを使って、MacとWindowsの両方で動くデスクトップアプリを作ることができるフレームワークです。ElectronはChromiumとNode.jsがベースとなっており、Chromeで動くページであればそのままデスクトップアプリ化させることも可能です。

作成したデスクトップアプリは、MacのAppStoreやMicrosoftのストアで公開することも可能です。

■前回の記事
Electronを使ってMacとWindowsで動くアプリを作ってみる
[https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f](https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f)

## Reactとは？

Reactは、UIを作るためのJavaScriptのライブラリーです。Vue.jsと同じく、要素をコンポーネント化してUIを作れるため、メンテナンスが行いやすいです。また、UI上の表示とJavaScirptで持つデータを同期できるため、複雑なUIをシンプルに表現できます。

[https://reactjs.org/](https://reactjs.org/)

## ElectronとReactを使うメリット

- JavaScriptを使って複雑なUIを持つデスクトップアプリケーションが作れる
- Web系のエンジニアでもアプリのメンテナンスがしやすい
- Web用に作成したReactプロジェクトをデスクトップアプリにできる

## Electron React Boilerplateを使って、Electron + Reactの実行環境を作成する

ElectronでReactを使うには手間が必要ですが、「Electron React Boilerplate」を使えば、細かな設定を行わなくとも簡単にElectron + Reactの実行環境が作れます。

今回は[Electron React Boilerplate](https://electron-react-boilerplate.js.org/docs/installation/)を使って、ToDo管理アプリを作ってみます。


### プロジェクトをcloneしてnpm installを実行します


```terminal
git clone --depth=1 \
  https://github.com/electron-react-boilerplate/electron-react-boilerplate \
  my-todo-app

cd my-todo-app

npm install
```

### 開発環境でアプリを実行します

```terminal
npm start
```

アプリが無事に立ち上がりました。

![スクリーンショット 2022-06-17 14.30.28.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/da72f2b0-400e-98ef-8a2a-94ea5acfb0c4.jpeg)


これでElectron + Reactの実行環境が完成しました！🎉

簡単ですね。ここまで所要時間は1分です。ホットリロードに対応しており、ソースコードに変更を行うと即座にアプリの表示も更新されます。

### フォルダ構成

フォルダ構成はこのようになっています。

![スクリーンショット 2022-06-17 14.35.01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/06e81692-2f61-4ff0-9625-b4b7945f116e.jpeg)

/srcディレクトリにメインの処理を書いていきます。

/src/mainにはElectronやnode.js周りの処理を書いて、

/src/renderer内にReactのコンポーネントを書きます。

/assets内には画像やアイコンなどを置いておきます。

/release内にはビルドしたアプリが保存されます。

### TypeScriptが使える

また、「Electron React Boilerplate」では、最初からTypeScriptが使えるようになっています。TypeScriptを使うと型チェックができたりコードエディターで補完が効くようになるため、複雑で大規模な開発でもデバッグしやすくメンテナンスが楽になります。

TypeScriptファイルの拡張子には、.tsと.tsxの2つが存在します。

.tsは純粋なTypeScriptを含むファイルに使われ、

.tsxは、JSXに対応したTypeScriptのファイルに使われます。

## Electron + ReactでToDoアプリを作る

/src/renderer/app.tsxを変更して、ToDoリストアプリを作ります。

ReactでToDoアプリを作り、Electronを使ってToDoデータをローカルに保存できるようにします。

### App.tsx > App

```tsx
import { useState, useEffect } from 'react';
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import './App.css';

// データ型を定義
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

export default function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<HomeScreen />} />
      </Routes>
    </Router>
  );
}
```

Routerが最初から設定されているため、後からページやスクリーンを楽に追加できます。

elementに指定したコンポーネントが表示されます。

### App.tsx > HomeScreen

```tsx
const HomeScreen = () => {
  // stateを定義
  const [text, setText] = useState<string>('');
  const [todoList, setTodoList] = useState<Array<Todo>>([]);

  useEffect(() => {
    // 初回レンダー時にデフォルトのデータをセット
    const defaultTodoList = [
      {
        id: 1,
        text: '宿題をやる',
        completed: false,
      },
      {
        id: 2,
        text: '部屋を片付ける',
        completed: true,
      },
      {
        id: 3,
        text: 'メールを送る',
        completed: false,
      },
    ];

    setTodoList(defaultTodoList);
  }, []);

  const onSubmit = () => {
    // ボタンクリック時にtodoListに新しいToDoを追加
    if (text !== '') {
      const newTodoList: Array<Todo> = [
        {
          id: new Date().getTime(),
          text: text,
          completed: false,
        },
        ...todoList,
      ];
      setTodoList(newTodoList);

      // テキストフィールドを空にする
      setText('');
    }
  };

  const onCheck = (newTodo: Todo) => {
    // チェック時にcompletedの値を書き換える
    const newTodoList = todoList.map((todo) => {
      return todo.id == newTodo.id
        ? { ...newTodo, completed: !newTodo.completed }
        : todo;
    });
    setTodoList(newTodoList);
  };

  return (
    <div>
      <div className="container">
        <div className="input-field">
          <input
            type="text"
            value={text}
            onChange={(e) => setText(e.target.value)}
          />
          <button onClick={onSubmit} className="add-todo-button">
            追加
          </button>
        </div>

        <ul className="todo-list">
          {todoList?.map((todo) => {
            return <Todo key={todo.id} todo={todo} onCheck={onCheck} />;
          })}
        </ul>
      </div>
    </div>
  );
};
```

### App.tsx >Todo

```tsx
const Todo = (props: { todo: Todo; onCheck: Function }) => {
  const { todo, onCheck } = props;
  const onCheckHandler = () => {
    onCheck(todo);
  };
  return (
    <li className={todo.completed ? 'checked' : ''}>
      <label>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={onCheckHandler}
        ></input>
        <span>{todo.text}</span>
      </label>
    </li>
  );
};
```

### App.css

```css

body {
  position: relative;
  color: white;
  height: 100vh;
  background: linear-gradient(
    200.96deg,
    #fedc2a -29.09%,
    #dd5789 51.77%,
    #7a2c9e 129.35%
  );
  font-family: sans-serif;
  overflow-y: hidden;
}

.add-todo-button {
  background-color: #da568a;
  color: white;
  border: 1px solid #fff;
  padding: 8px 12px;
  border-radius: 4px;
  appearance: none;
  font-size: 12px;
  transition: all ease-in 0.1s;
  cursor: pointer;
  opacity: 0.9;
  line-height: 1;
  min-width: 60px;
}

.add-todo-button:hover {
  background-color: #e47474;
  opacity: 1;
}

input[type='text'] {
  border: none;
  border-radius: 4px;
  min-height: 30px;
  margin-right: 12px;
  opacity: 0.9;
  padding: 4px 12px;
  width: 100%;
}

.input-field {
  width: 100%;
  display: flex;
  opacity: 0.95;
}

.container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  width: 100%;
  max-width: 400px;
  margin: 40px auto;
}

.todo-list li {
  line-height: 2;
}

.todo-list li input,
.todo-list li span {
  cursor: pointer;
}

ul {
  padding: 0;
}

li {
  list-style: none;
}

a {
  text-decoration: none;
  height: fit-content;
  width: fit-content;
  margin: 10px;
}

a:hover {
  opacity: 1;
  text-decoration: none;
}

.Hello {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: 20px 0;
}

.checked {
  text-decoration: line-through;
  opacity: 0.5;
}
```

### npm run startで実行！

![スクリーンショット 2022-06-20 11.05.24.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/59062df2-4fd3-b8be-84d0-f898293a9bee.jpeg)


うまく表示されました！

チェックボックスの切り替えや、新しいToDoの追加もできます。

![スクリーンショット 2022-06-20 11.07.58.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/7a42fa65-87d3-9dd9-e4d7-fe3a575ccae4.jpeg)


## ****electron-storeを使って、データを保存させる****

ここまでで、ToDoを追加したりチェックボックをチェックできるようになりましたが、アプリのウインドウを閉じるとデータが消えてしまいます。

そこで、ウインドウを閉じてもデータが保存されるように、electron-storeを使います。

electron-storeを使うと、データをjson形式で保存したり読み込んだりできます。

[https://github.com/sindresorhus/electron-store](https://github.com/sindresorhus/electron-store)

### contextBridgeの設定が必要

electron-storeは~~Electronのメインプロセス側で動作します。~~
メインでもレンダープロセスのどちらでも動作するようです。

Electronでは、レンダラーからメインプロセスの処理を呼び出すには「contextBridge」というモジュールを使う必要があります。

今回はmain.tsにメインの処理を追加して、preload.tsでcontextBridgeの設定を行います。

詳しくは、前回の記事を参考にしてください。

[https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f#contextbridge](https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f#contextbridge)

### electron-storeをインストール

まずは、electron-storeをインストールします。

```terminal
npm install electron-store
```

### main.ts

main.tsに処理を追加します。

ToDoを読み込む「loadTodoList」と、データを保存する「storeTodoList」を用意します。

```tsx
import Store, { Schema } from 'electron-store';

ipcMain.handle('loadTodoList', async (event, data) => {
  return storeData.get('todoList');
});

ipcMain.handle('storeTodoList', async (event, data) => {
  storeData.set('todoList', data);
});
```

### preload.ts

次に、preload.tsで、レンダラーへ開放するAPIの設定をします。

これで、React側から、window.db.loadTodoList();のような書き方で、main.tsに書いた処理を実行できます。

```tsx
contextBridge.exposeInMainWorld('db', {
  loadTodoList: () => ipcRenderer.invoke('loadTodoList'),
  storeTodoList: (todoList: Array<object>) =>
    ipcRenderer.invoke('storeTodoList', todoList),
});
```

### app.tsx

最後にapp.tsxに処理を追加します。

```tsx
// データ操作
// ToDoリストを読み込み
const loadTodoList = async (): Promise<Array<Todo> | null> => {
  const todoList = await window.db.loadTodoList();
  return todoList;
};

// ToDoリストを保存
const storeTodoList = async (todoList: Array<Todo>): Promise<void> => {
  await window.db.storeTodoList(todoList);
};
```

アプリ起動時に、ローカルに保存されたデータが読み込まれるようにしたいので、
HomeScreenのuseEffectを書き換えてloadTodoList()を呼び出します。

```tsx
useEffect(() => {
    // 初回レンダー時にデフォルトデータをセット
    // const defaultTodoList = [
    //   {
    //     id: 1,
    //     text: '宿題をやる',
    //     completed: false,
    //   },
    //   {
    //     id: 2,
    //     text: '部屋を片付ける',
    //     completed: false,
    //   },
    //   {
    //     id: 3,
    //     text: 'メールを送る',
    //     completed: false,
    //   },
    // ];

    // setTodoList(defaultTodoList);

    loadTodoList().then((todoList) => {
      if (todoList) {
        setTodoList(todoList);
      }
    });
  }, []);
```

また、ToDoの追加時とチェックボックスを切り替えた場合にデータを保存したいので、onSubmitとonCheck実行時に、storeTodoList()を呼び出します。

```tsx
const onSubmit = () => {
    if (text !== '') {
      const newTodoList: Array<Todo> = [
        {
          id: new Date().getTime(),
          text: text,
          completed: false,
        },
        ...todoList,
      ];
      setTodoList(newTodoList);
      storeTodoList(newTodoList); // これを追加

      setText('');
    }
  };
```

```tsx
const onCheck = (newTodo: Todo) => {
    const newTodoList = todoList.map((todo) => {
      return todo.id == newTodo.id
        ? { ...newTodo, completed: !newTodo.completed }
        : todo;
    });
    setTodoList(newTodoList);
    storeTodoList(newTodoList);// これを追加
  };
```

このままだと、window.dbの行が型エラーとなるので、以下を追加します。

```tsx
interface ElectronWindow extends Window {
  db: {
    loadTodoList: () => Promise<Array<Todo> | null>;
    storeTodoList: (todoList: Array<Todo>) => Promise<void>;
  };
}

declare const window: ElectronWindow;
```

これでToDoリストの保存と読み込みが行われるようになりました。

アプリを開きなおしても終了時と同じデータを読み込めることが確認できました。

![スクリーンショット 2022-06-20 11.53.27.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/b1a3eee7-e6de-2da0-e4a5-142aca72cdb4.jpeg)

### app.tsx（全体）
```tsx
import { useState, useEffect } from 'react';
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import './App.css';

// データ型を定義
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface ElectronWindow extends Window {
  db: {
    loadTodoList: () => Promise<Array<Todo> | null>;
    storeTodoList: (todoList: Array<Todo>) => Promise<void>;
  };
}

declare const window: ElectronWindow;

// データ操作
// ToDoリストを読み込み
const loadTodoList = async (): Promise<Array<Todo> | null> => {
  const todoList = await window.db.loadTodoList();
  return todoList;
};

// ToDoリストを保存
const storeTodoList = async (todoList: Array<Todo>): Promise<void> => {
  await window.db.storeTodoList(todoList);
};

export default function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<HomeScreen />} />
      </Routes>
    </Router>
  );
}

const HomeScreen = () => {
  // stateを定義
  const [text, setText] = useState<string>('');
  const [todoList, setTodoList] = useState<Array<Todo>>([]);

  useEffect(() => {
    loadTodoList().then((todoList) => {
      if (todoList) {
        setTodoList(todoList);
        console.log(todoList);
      }
    });
  }, []);

  const onSubmit = () => {
    // ボタンクリック時にtodoListに新しいToDoを追加
    if (text !== '') {
      const newTodoList: Array<Todo> = [
        {
          id: new Date().getTime(),
          text: text,
          completed: false,
        },
        ...todoList,
      ];
      setTodoList(newTodoList);
      storeTodoList(newTodoList);

      // テキストフィールドを空にする
      setText('');
    }
  };

  const onCheck = (newTodo: Todo) => {
    // チェック時にcompletedの値を書き換える
    const newTodoList = todoList.map((todo) => {
      return todo.id == newTodo.id
        ? { ...newTodo, completed: !newTodo.completed }
        : todo;
    });
    setTodoList(newTodoList);
    storeTodoList(newTodoList);
  };

  return (
    <div>
      <div className="container">
        <div className="input-field">
          <input
            type="text"
            value={text}
            onChange={(e) => setText(e.target.value)}
          />
          <button onClick={onSubmit} className="add-todo-button">
            追加
          </button>
        </div>

        <ul className="todo-list">
          {todoList?.map((todo) => {
            return <Todo key={todo.id} todo={todo} onCheck={onCheck} />;
          })}
        </ul>
      </div>
    </div>
  );
};

const Todo = (props: { todo: Todo; onCheck: Function }) => {
  const { todo, onCheck } = props;
  const onCheckHandler = () => {
    onCheck(todo);
  };
  return (
    <li className={todo.completed ? 'checked' : ''}>
      <label>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={onCheckHandler}
        ></input>
        <span>{todo.text}</span>
      </label>
    </li>
  );
};

```

### データはjsonファイルで保存されます
![スクリーンショット 2022-06-20 13.36.53.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/a2f3d4f9-37f0-37a3-d7cc-e1406237d542.jpeg)
electron-storeを使って保存したデータは、config.jsonというファイル名で保存されます。
ファイルの保存場所は、app.getPath('userData')で取得できるパスです。

Macでは以下に保存されていました。
```/Users/ユーザー名/Library/Application Support/electron/config.json```


## 完成したアプリをパッケージ化して配布する

Electron React Boilerplateで作成したアプリは、npm run packageを実行することでパッケージ化して配布することができるようになります。

```terminal
npm run package
```

しばらく待つとパッケージが完成しました。

![スクリーンショット 2022-06-20 13.22.50.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/96b264c3-d907-5791-419a-54acf606bc67.jpeg)


出来上がったzipファイルを配布すれば、他のユーザーも使えるようになります。


## 関連記事
よかったら、こちらの記事も読んでみてください。

reactでポケモン風RPGゲームを作ってみよう！戦闘画面編
https://qiita.com/udayaan/items/38680c63ed034503eac0

話題の最新フロントエンドフレームワーク「Astro」を使ってみた
https://qiita.com/udayaan/items/24ecb2f5f4608fc1df4c

Electronを使ってMacとWindowsで動くアプリを作ってみる
https://qiita.com/udayaan/items/dfb324bc6cadeb9a8f6f