TypeScript+React+Electronでデスクトップアプリケーションを作ってみる

環境構築

まずTypeScript+React+Electronをビルドできる環境を作る。作るのはpackage.json/tsconfig.json/vite.config.js

それぞれを作っていく

package.json

{
  "main": "dist/main.js",
  "license": "MIT",
  "scripts": {
    "check": "tsc --noEmit",
    "build": "npm run check && vite build",
    "start": "electron ."
  },
  "devDependencies": {
    "@types/react": "^19.2.0",
    "@types/react-dom": "^19.2.0",
    "@vitejs/plugin-react-swc": "^4.1.0",
    "electron": "^39.0.0",
    "typescript": "^5.9.3",
    "vite": "^7.1.9",
    "vite-plugin-electron": "^0.29.0"
  },
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-router-dom": "^7.9.4"
  }
}

dependenciesで実行に必要なReactの依存性を設定。devDependenciesでTypeScriptやviteでビルドする環境に必要なのを設定しておく。今回は普通にvite-plugin-electronを使って作る

mainにはElectronアプリが起動するのに使うエントリーポイントファイルを指定する

tsconfig.json

{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "node_modules/.tsBuildInfo",
    "noEmit": true,
    "noUnusedLocals": true,
    "skipLibCheck": true,
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "react-jsx",
    "esModuleInterop": true,
    "isolatedModules": true,
    "types": ["node", "electron"]
  },
  "include": ["src/**/*"]
}

typesでnodeとelectronの型定義を有効にしておく。あとは省略

vite.config.js

import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import electron from 'vite-plugin-electron';
import react from '@vitejs/plugin-react-swc';

export default defineConfig({
  base: './',
  plugins: [
    react(),
    electron([
      {
        entry: 'src/main/main.ts',
        vite: {
          build: {
            outDir: 'dist'
          }
        }
      },
      {
        entry: 'src/ipc/preload.ts',
        vite: {
          build: {
            outDir: 'dist'
          }
        }
      }
    ])
  ],
  build: {
    minify: 'esbuild',
    outDir: './dist',
    emptyOutDir: true,
    copyPublicDir: false,
    rollupOptions: {
      onwarn(warning, warn) {
        if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
          return;
        }

        warn(warning);
      },
      input: {
        index: resolve(__dirname, 'index.html'),
      }
    },
  },
});

vite-plugin-electornを使ってElectronアプリをビルドするように設定

これでビルドできる環境はできたので上記にあるアプリのファイルを作っていく

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./src/renderer/index.tsx"></script>
  </body>
</html>

<script type=module>でReactで表示するエントリーポイントなファイルを指定

あとはガシガシ実装を書いていくだけ。ちなみに以下のファイルを作っていくがrenderer/index.tsxはapp.tsxを描画してるだけなので省略する

├── ipc
│   └── preload.ts
├── main
│   └── main.ts
└── renderer
    ├── app.tsx
    ├── electron.d.ts
    └── index.tsx

作るアプリについて

ざっくりとElectronで画面を作ってそれをReactでレンダリング。でReactでレンダリングする際にReact側からElectronのプロセス側にデータを要求してElectron側からデータを返す。でそれを描画するだけ

infoちなみに逆(Electron側からReact側にデータをプッシュ)も可能なのでそれは余談で書く

src/main/main.ts

Electronアプリケーションのエントリーポイントファイル

import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'node:path';

app.disableHardwareAcceleration();

const setupIPCListener = (): void => {
  ipcMain.handle('file:request-file', async (event) => {
    return '/path/to/test/test.jpg';
  });
};

const createWindow = async (): Promise<BrowserWindow> => {
  const win = new BrowserWindow({
    darkTheme: true,
    autoHideMenuBar: true,
    center: true,
    webPreferences: {
      preload: path.resolve(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      webSecurity: true,
    },
  });
  await win.loadFile(path.resolve(__dirname, 'index.html'));

  return win;
};

app.whenReady().then(async () => {
  setupIPCListener();
  const win = await createWindow();
}).catch(() => app.quit());

file:request-fileっていうのはReact側からElectronプロセス側にデータを要求する際に使うチャネル名

でElectronアプリプロセス側とReactでレンダリングしている側と繋げるためのipcスクリプトが必要なのでそれをipc/preload.tsに定義する

src/ipc/preload.ts

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
  requestFile: (): Promise<string> => {
    return ipcRenderer.invoke("file:request-file");
  }
});

main.ts側で定義したElectronメインプロセス側にデータ要求リクエストを送信してデータを受信するだけ。invokeの引数にはElectronメインプロセスで指定したチャネル名を指定する

これでElectronアプリ側の実装は終わり。あとはReactでレンダリングする側のアプリケーションの実装を作る

src/renderer/electron.d.ts

まあただの型定義なんで(ry

type ElectronAPI = {
  requestFile: () => Promise<string>
};

declare global {
  interface Window {
    electronAPI: ElectronAPI
  }
}

export {}

src/renderer/app.tsx

import { useEffect, useState } from 'react';

const App = (): React.JSX.Element => {
  const [ file, setFile ] = useState<string | null>(null);

  // renderer(React) -> electron -> renderer(React)
  useEffect(() => {
    (async () => {
      const requestFile = await window.electronAPI.requestFile();
      setFile(requestFile);
    })();
  }, []);

  if (file === null) {
    return (<div>loading...</div>);
  }

  return (
    <img src={file} />
  );
};

export default App;

終わり。これでビルドするとReact側でwindow.electronAPI.requestFile();でElectronメインプロセスにデータを要求する、そしてメインプロセス側のipcMain.handleが対応するチャネルで処理されて値がReact側に返されそれが描画される(今回はただ画像を表示しているだけなのでそれが表示されるだけ)

んまあという感じでElectronでデスクトップアプリを作ってそのUIとかをReactとかで作ったりすることもできるよ〜ってことで終わり

余談1

今回のは紹介用に一部を切り出して作ったやつなので切り出してないやつはhttps://github.com/kinjouj/typescript-react-electron-photoviewerにあります

余談2: ElectronメインプロセスからReactレンダラープロセスに値をぶんなげる方法

上記で書いた方法はReact側からメインプロセス側にデータ要求リスエストを出してそれを受け取るような形でやったがメインプロセス側からReactなどのレンダリング側にデータを受け取らせるようなこともできる。なのでそういう方法を取りたい場合は以下のように修正する

src/ipc/preload.ts
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
  onReceived: (callback: (data: string) => void): void => {
    ipcRenderer.on('file:request-file', (event, data: string) => callback(data));
  }
});

んまあReact側からElectronメインプロセスから送られてくるデータを受信するAPIを定義

src/renderer/electron.d.ts
type ElectronAPI = {
  onReceived: (data) => Promise<string>
};

declare global {
  interface Window {
    electronAPI: ElectronAPI
  }
}

export {}

単純にElectronAPI側の定義を設定

src/renderer/app.tsx
import { useEffect, useState } from 'react';

const App = (): React.JSX.Element => {
  const [ file, setFile ] = useState<string | null>(null);

  useEffect(() => {
    window.electronAPI.onReceived((data: string)  => {
      setFile(data);
    });
  });

  if (file === null) {
    return (<div>loading...</div>);
  }

  return (
    <img src={file} />
  );
};

export default App;

定義したonReceivedを使ってデータを受信してそれを描画する

src/main/main.ts
app.whenReady().then(async () => {
  const win = await createWindow();
  setTimeout(() => {
    win.webContents.send('file:request-file', '/path/to/test/test.jpg');
  }, 3000);
}).catch(() => app.quit());

BrowserWindow.webContents.sendを使ってチェネル名でデータを送信。これでElectronアプリ側からReactなどで表示してるレンダラープロセス側でデータを受信したりできるようになる

infoただし場合によっては受信するコールバックの登録が遅延してデータが正常に受け取れなくなる場合もあるので注意