⚡️

Electron + Reactをさわってみる(ToDoリスト)

2025/02/01に公開

はじめに

Electronとは

JavaScript、HTML、CSS によるデスクトップアプリケーションを構築するフレームワーク

です。興味はありましたが、あまり触ったことなかったので改めて入門してみました。

https://www.electronjs.org/ja/docs/latest/

ついでに、UIの実装にはReactを使います。
こちらも理解を深めてみました。

試したみたこと

要素の追加、削除、修正できるToDoリストをつくりました。
下記の記事を参考にして、削除・修正の要素を加えています。

https://qiita.com/udayaan/items/2a7c8fd0771d4d995b69

alt text

ElectronでReactをあつかう

Electron React Boilerplateをクローンして使います。

https://github.com/electron-react-boilerplate/electron-react-boilerplate

ソースコード全体

main.ts

main.ts

import path from 'path';
import { app, BrowserWindow, shell, ipcMain } from 'electron';
import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
import MenuBuilder from './menu';
import { resolveHtmlPath } from './util';
import Store, { Schema } from 'electron-store'

class AppUpdater {
  constructor() {
    log.transports.file.level = 'info';
    autoUpdater.logger = log;
    autoUpdater.checkForUpdatesAndNotify();
  }
}

let mainWindow: BrowserWindow | null = null;

ipcMain.on('ipc-example', async (event, arg) => {
  const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
  console.log(msgTemplate(arg));
  event.reply('ipc-example', msgTemplate('pong'));
});

if (process.env.NODE_ENV === 'production') {
  const sourceMapSupport = require('source-map-support');
  sourceMapSupport.install();
}

const isDebug =
  process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';

if (isDebug) {
  require('electron-debug')();
}

const installExtensions = async () => {
  const installer = require('electron-devtools-installer');
  const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
  const extensions = ['REACT_DEVELOPER_TOOLS'];

  return installer
    .default(
      extensions.map((name) => installer[name]),
      forceDownload,
    )
    .catch(console.log);
};

const createWindow = async () => {
  if (isDebug) {
    await installExtensions();
  }

  const RESOURCES_PATH = app.isPackaged
    ? path.join(process.resourcesPath, 'assets')
    : path.join(__dirname, '../../assets');

  const getAssetPath = (...paths: string[]): string => {
    return path.join(RESOURCES_PATH, ...paths);
  };

  mainWindow = new BrowserWindow({
    show: false,
    width: 1024,
    height: 728,
    icon: getAssetPath('icon.png'),
    webPreferences: {
      preload: app.isPackaged
        ? path.join(__dirname, 'preload.js')
        : path.join(__dirname, '../../.erb/dll/preload.js'),
    },
  });

  mainWindow.loadURL(resolveHtmlPath('index.html'));

  mainWindow.on('ready-to-show', () => {
    if (!mainWindow) {
      throw new Error('"mainWindow" is not defined');
    }
    if (process.env.START_MINIMIZED) {
      mainWindow.minimize();
    } else {
      mainWindow.show();
    }
  });

  mainWindow.on('closed', () => {
    mainWindow = null;
  });

  const menuBuilder = new MenuBuilder(mainWindow);
  menuBuilder.buildMenu();

  // Open urls in the user's browser
  mainWindow.webContents.setWindowOpenHandler((edata) => {
    shell.openExternal(edata.url);
    return { action: 'deny' };
  });

  // Remove this if your app does not use auto updates
  // eslint-disable-next-line
  new AppUpdater();
};

/**
 * Add event listeners...
 */

app.on('window-all-closed', () => {
  // Respect the OSX convention of having the application in memory even
  // after all windows have been closed
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app
  .whenReady()
  .then(() => {
    createWindow();
    app.on('activate', () => {
      // On macOS it's common to re-create a window in the app when the
      // dock icon is clicked and there are no other windows open.
      if (mainWindow === null) createWindow();
    });
  })
  .catch(console.log);

// ---- 新規追加 ---- 
const storeData = new Store();

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

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

ipcMain.handle('deleteTodoList', async (event, id) => {
  const todoList = storeData.get('todoList');
  const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
  storeData.set('todoList', updateTodoList);
  console.log("updateList : " + JSON.stringify(updateTodoList))
  return updateTodoList;
})
// ---- 新規追加 ---- 

preload.ts

preload.ts

// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';

export type Channels = 'ipc-example';

const electronHandler = {
  ipcRenderer: {
    sendMessage(channel: Channels, ...args: unknown[]) {
      ipcRenderer.send(channel, ...args);
    },
    on(channel: Channels, func: (...args: unknown[]) => void) {
      const subscription = (_event: IpcRendererEvent, ...args: unknown[]) =>
        func(...args);
      ipcRenderer.on(channel, subscription);

      return () => {
        ipcRenderer.removeListener(channel, subscription);
      };
    },
    once(channel: Channels, func: (...args: unknown[]) => void) {
      ipcRenderer.once(channel, (_event, ...args) => func(...args));
    },
  },
};

export type ElectronHandler = typeof electronHandler;

// ---- 新規追加 ---- 
contextBridge.exposeInMainWorld('electron', electronHandler);

contextBridge.exposeInMainWorld('db', {
  loadTodoList: () => ipcRenderer.invoke('loadTodoList'),
  storeTodoList: (todoList: Array<object>) => 
    ipcRenderer.invoke('storeTodoList', todoList),
  deleteTodoList: (id: number) => ipcRenderer.invoke('deleteTodoList', id),
});
// ---- 新規追加 ---- 

App.tsx

App.tsx

import { MemoryRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
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>;
    deleteTodoList: (id: number) => Promise<Array<Todo> | null>;
  }
}

declare const window: ElectronWindow;

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

// データ操作
// 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);
};

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

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

  const onSubmit = () => {
    // ボタンクリック時にtodoListに新しいToDOを追加
    if(text !== ''){
      const newTodoList: Array<Todo> = [
        {
          id: new Date().getTime(),
          text: text,
          completed: false
        },
        ...todoList, // スプレッド構文
      ];
      console.log("newToDoList : " + JSON.stringify(newTodoList))
      setTodoList(newTodoList);
      storeTodoList(newTodoList);
      // テキストフィールドを空にする
      setText('');
    }
  }
  
  const onCheck = (newTodo: Todo) => {
    const newTodoList = todoList.map((todo) => {
      return todo.id == newTodo.id
        ?{...newTodo, completed: !newTodo.completed}
        : todo;
    });
    console.log("newTodoList : " + newTodoList);
    setTodoList(newTodoList);
    storeTodoList(newTodoList);
  }

  const onDelete = (id: number) => {
    const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
    console.log("updateToDoList : " + updateTodoList);
    setTodoList(updateTodoList)
    storeTodoList(updateTodoList)
  }

  const onUpdate = (id: number, text: string) => {
    const updateTodoList = todoList.map((todo) => {
      return todo.id == id
              ? {...todo, text: text} 
              : todo
  });
    console.log("updateToDoList : " + JSON.stringify(updateTodoList));
    setTodoList(updateTodoList)
    storeTodoList(updateTodoList)
  }
  
  return(
    <div>
      <div className='container'>
        <div className='input-field'>
          <input
            type='text'
            value={text}
            onChange={(e) => setText(e.target.value)}
          />
          <button onClick={onSubmit} className='common-button add-todo-button'>
            追加
          </button>
        </div>
  
        <ul className='todo-list'>
          {todoList?.map((todo) => {
            return <Todo key ={todo.id} 
            todo={todo} 
            onCheck={onCheck} 
            onDelete={onDelete}
            onUpdate={onUpdate}
            />;
          })}
        </ul>
  
      </div>
    </div>
  )

}

const Todo = (props: { todo: Todo; onCheck: Function; onDelete: (id: number) => void; 
              onUpdate: Function }) => {
  const {todo, onCheck, onDelete, onUpdate} = props;
  const [editText, setEditText] = useState(todo.text);
  // チェック
  const onCheckHandler = () => {
    onCheck(todo);
  }
  // 削除 
  const onDeleteHandler = () => {
    onDelete(todo.id);
  }
  // 編集
  const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditText(e.target.value)
    onUpdate(todo.id, editText)
  }

  return (
    <li>
      <label>
        <input
          type='checkbox'
          checked={todo.completed}
          onChange={onCheckHandler}
          ></input>
          <input
            id={`input-content-${todo.id}`}
            className='input-content'
            type='text'
            value={editText} // 編集中のテキスト
            onChange={handleEditChange}
            disabled={todo.completed} 
          />
        <button 
          className='common-button add-delete-button'
          onClick={onDeleteHandler}>削除</button>
      </label>
    </li>
  );
};


App.css

App.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;
  display: flex;
  justify-content: center;
  align-items: center;
}

button {
  background-color: white;
  padding: 10px 20px;
  border-radius: 10px;
  border: none;
  appearance: none;
  font-size: 1.3rem;
  box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12),
    0px 18px 88px -4px rgba(24, 39, 75, 0.14);
  transition: all ease-in 0.1s;
  cursor: pointer;
  opacity: 0.9;
}

button:hover {
  transform: scale(1.05);
  opacity: 1;
}

li {
  list-style: none;
}

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

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

.common-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;
}

.fix-button {
  margin-left: 10px;
}

.common-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;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 0;
}

.todo-list li input,
.todo-list li span{
  cursor: pointer;
  padding: 8px 12px;
  display: inline-block;
}

/* テキスト部分をスペースに応じて伸縮 */
.todo-list li span {
  flex-grow: 1;
}

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

.checked {
  text-decoration: line-through;
}

label {
  display: flex;
  align-items: center;
  gap: 8px;
}

詳細

main.ts

electron-storeを使って、ToDoリストをローカルに保存します。

https://github.com/sindresorhus/electron-store

メインプロセスからelectron-storeを扱うため、ReactのcontentBridgeIPC(Inter-Process Communication)通信をつかって、レンダラープロセスと安全にデータのやり取りを行います。

https://www.electronjs.org/docs/latest/api/context-bridge

ipcMain.handleをつかって、invoke可能な IPCのハンドラを追加します。こうすると、レンダープロセスから

https://www.electronjs.org/ja/docs/latest/api/ipc-main

const storeData = new Store();

// ToDoリストを読み込み
ipcMain.handle('loadTodoList', async (event, data) => {
  return storeData.get('todoList');
});

// ToDoリストを保存
ipcMain.handle('storeTodoList', async (event, data) => {
  storeData.set('todoList', data);
});

// 指定したidのToDoを削除
ipcMain.handle('deleteTodoList', async (event, id) => {
  const todoList = storeData.get('todoList');
  const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
  storeData.set('todoList', updateTodoList);
  return updateTodoList;
})

preload.ts

ElectronではcontextIsolationがデフォルトで有効になっているため、レンダラープロセスから直接ipcRenderにアクセスできません。そのためcontextBridgeをつかって、安全にメインプロセスと通信できるAPIを提供します。

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

export type ElectronHandler = typeof electronHandler;

App.tsx

まず、HomeScreen関数での処理を説明します。

データ操作

preload.tswindow.dbに公開したAPIを使って、Reactから ToDoリストを管理します。これでToDoリストへの読み込みや保存、削除ができるようになります。

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

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

Todoリストの状態管理

Todoリストを管理するためuseStateを定義しています。

const [text, setText] = useState<string>('');
const [todoList, setTodoList] = useState<Array<Todo>>([]);

データの読み込み

useEffectを使って、アプリが初回レンダリングされたときに、Todoリストを読み込みます。useEffectはコンポーネントがレンダリングされたとき実行されるhookです。

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

ToDoの追加

新しいToDo をリストに追加する関数です。
UI側のボタンが押されたときに実行されます。
スプレッド構文をつかって、新しいToDoをtodoListの先頭に追加します。追加後はuseStateで定義したsetTextをつかって、入力フィールドをリセットします。

const onSubmit = () => {
  if (text !== '') {
    const newTodoList: Array<Todo> = [
      {
        id: new Date().getTime(),
        text: text,
        completed: false
      },
      ...todoList, // スプレッド構文
    ];
    console.log("newToDoList : " + JSON.stringify(newTodoList));
    setTodoList(newTodoList);
    storeTodoList(newTodoList);
    setText(''); // 入力フィールドをリセット
  }
};

ToDoの完了状態を切り替え

チェックボックスがクリックされたとき、completed(完了状態)を反転させる関数です。反転して、新しいリストをstoreTodoListで保存しています。

const onCheck = (newTodo: Todo) => {
  const newTodoList = todoList.map((todo) => {
    return todo.id === newTodo.id
      ? { ...newTodo, completed: !newTodo.completed } // completed を切り替え
      : todo;
  });
  setTodoList(newTodoList);
  storeTodoList(newTodoList);
};

ToDoの削除

指定したidのToDoをリストから削除する関数です。

const onDelete = (id: number) => {
  const updateTodoList = todoList.filter((todo: Todo) => todo.id !== id);
  setTodoList(updateTodoList);
  storeTodoList(updateTodoList);
};

ToDoの更新

指定したidのToDoのtextを更新する関数です。

const onUpdate = (id: number, text: string) => {
  const updateTodoList = todoList.map((todo) => {
    return todo.id === id
      ? { ...todo, text: text } 
      : todo;
  });
  setTodoList(updateTodoList);
  storeTodoList(updateTodoList);
};

コンポーネントのJSX

コンポーネントのJSXは下記のようになっていて、todoListをループしてToDoコンポーネントを描画します。

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

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

おわりに

ElectronではcontextBridgeという仕組みがあることを初めて知りました。ToDoリストをつくってみてReactとElectronの理解が少し深まりました。

参考

https://qiita.com/udayaan/items/2a7c8fd0771d4d995b69

https://zenn.dev/sunnyheee/articles/1633750ade59ac

https://zenn.dev/kontam/articles/c00770c22e8e17

Discussion