Lesson 04-Electron State Management

In Electron development, state management is critical for building complex applications, particularly when dealing with interactions between multiple windows (renderer processes) and the main process. State management libraries like Vuex and Redux help organize and manage data, while Electron’s inter-process communication (IPC) ensures state synchronization between the main and renderer processes. This tutorial explores how to implement state management in Electron using Vuex and Redux, combined with IPC for cross-process data sharing and updates.

Electron State Management Basics

Why State Management is Needed

In Electron applications, state management addresses:

  • Data Consistency: Ensures multiple windows display the same data.
  • Complex Interactions: Manages user actions and UI updates.
  • Cross-Process Synchronization: Shares state between main and renderer processes.

Challenges of State Management in Electron

  • Process Isolation: Main and renderer processes run in separate environments.
  • Communication Overhead: Frequent IPC calls can impact performance.
  • Distributed State: Multiple renderer processes may maintain independent states.

Development Environment Setup

Initialize a Project

mkdir ElectronStateDemo
cd ElectronStateDemo
npm init -y
npm install electron --save-dev

Configure package.json

{
  "name": "electron-state-demo",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^28.2.2"
  }
}

State Management with Vuex

Configuring Vuex

Vuex is a state management library for Vue.js, suitable for Electron’s renderer processes.

Installation

npm install vue vuex vue-loader vue-template-compiler --save
npm install babel-loader @babel/core @babel/preset-env --save-dev

Configure Webpack (Optional, for Bundling Vue)

npm install webpack webpack-cli --save-dev
webpack.config.js
const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/renderer.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'renderer.bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: { presets: ['@babel/preset-env'] },
        },
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.vue'],
  },
};

Using Vuex in the Renderer Process

src/store.js

import Vuex from 'vuex';

export default new Vuex.Store({
  state: {
    tasks: [],
  },
  mutations: {
    addTask(state, task) {
      state.tasks.push(task);
    },
    removeTask(state, id) {
      state.tasks = state.tasks.filter(task => task.id !== id);
    },
  },
  actions: {
    addTask({ commit }, task) {
      commit('addTask', task);
    },
    removeTask({ commit }, id) {
      commit('removeTask', id);
    },
  },
});

src/App.vue

<template>
  <div>
    <h1>Task Manager</h1>
    <input v-model="newTask" @keypress.enter="addTask" placeholder="Enter task">
    <button @click="addTask">Add</button>
    <ul>
      <li v-for="task in tasks" :key="task.id">
        {{ task.text }}
        <button @click="removeTask(task.id)">Delete</button>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  data() {
    return {
      newTask: '',
    };
  },
  computed: {
    tasks() {
      return this.$store.state.tasks;
    },
  },
  methods: {
    addTask() {
      if (this.newTask.trim()) {
        this.$store.dispatch('addTask', { id: Date.now(), text: this.newTask });
        this.newTask = '';
      }
    },
    removeTask(id) {
      this.$store.dispatch('removeTask', id);
    },
  },
};
</script>

<style>
body { font-family: Arial, sans-serif; margin: 20px; }
ul { list-style: none; padding: 0; }
li { padding: 10px; background-color: #f0f0f0; margin-bottom: 5px; display: flex; justify-content: space-between; }
button { padding: 5px 10px; background-color: #dc3545; color: white; border: none; }
</style>

src/renderer.js

import Vue from 'vue';
import App from './App.vue';
import store from './store';

Vue.config.productionTip = false;

new Vue({
  store,
  render: h => h(App),
}).$mount('#app');

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Task Manager</title>
</head>
<body>
  <div id="app"></div>
  <script src="./dist/renderer.bundle.js"></script>
</body>
</html>

main.js

const { app, BrowserWindow } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    },
  });
  win.loadFile('index.html');
}

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

Build and Run

npx webpack
npm start

Analysis

  • Vuex.Store: Defines state, mutations, and actions.
  • Vue: Binds state to the UI.

Synchronizing State with IPC

main.js

const { app, BrowserWindow, ipcMain } = require('electron');

let tasks = [];

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });
  win.loadFile('index.html');
}

app.whenReady().then(createWindow);

ipcMain.handle('sync-tasks', (event, newTasks) => {
  tasks = newTasks;
  BrowserWindow.getAllWindows().forEach(win => {
    win.webContents.send('tasks-updated', tasks);
  });
  return tasks;
});

ipcMain.on('get-tasks', (event) => {
  event.reply('tasks-updated', tasks);
});

preload.js

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  syncTasks: (tasks) => ipcRenderer.invoke('sync-tasks', tasks),
  onTasksUpdated: (callback) => ipcRenderer.on('tasks-updated', (event, tasks) => callback(tasks)),
  getTasks: () => ipcRenderer.send('get-tasks'),
});

src/store.js

import Vuex from 'vuex';

export default new Vuex.Store({
  state: {
    tasks: [],
  },
  mutations: {
    addTask(state, task) {
      state.tasks.push(task);
      window.electronAPI.syncTasks(state.tasks);
    },
    setTasks(state, tasks) {
      state.tasks = tasks;
    },
  },
  actions: {
    addTask({ commit }, task) {
      commit('addTask', task);
    },
    loadTasks({ commit }) {
      window.electronAPI.onTasksUpdated((tasks) => commit('setTasks', tasks));
      window.electronAPI.getTasks();
    },
  },
});

src/App.vue

<script>
export default {
  mounted() {
    this.$store.dispatch('loadTasks');
  },
  // Remainder as above
};
</script>

Analysis

  • ipcMain.handle: Synchronizes tasks to the main process.
  • ipcRenderer: Facilitates communication between renderer and main processes.

State Management with Redux

Configuring Redux

Installation

npm install redux react-redux redux-thunk --save

Using Redux in the Renderer Process

src/store.js

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const initialState = { tasks: [] };

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'ADD_TASK':
      return { ...state, tasks: [...state.tasks, action.payload] };
    case 'REMOVE_TASK':
      return { ...state, tasks: state.tasks.filter(task => task.id !== action.payload) };
    case 'SET_TASKS':
      return { ...state, tasks: action.payload };
    default:
      return state;
  }
};

export default createStore(reducer, applyMiddleware(thunk));

src/App.js

import React, { useState } from 'react';
import { Provider, useSelector, useDispatch } from 'react-redux';
import store from './store';
import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native';

const TaskApp = () => {
  const [newTask, setNewTask] = useState('');
  const tasks = useSelector(state => state.tasks);
  const dispatch = useDispatch();

  const addTask = () => {
    if (newTask.trim()) {
      dispatch({ type: 'ADD_TASK', payload: { id: Date.now(), text: newTask } });
      setNewTask('');
    }
  };

  const renderItem = ({ item }) => (
    <View style={styles.item}>
      <Text>{item.text}</Text>
      <Button title="Delete" onPress={() => dispatch({ type: 'REMOVE_TASK', payload: item.id })} />
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Task Manager</Text>
      <TextInput
        style={styles.input}
        value={newTask}
        onChangeText={setNewTask}
        placeholder="Enter task"
      />
      <Button title="Add" onPress={addTask} />
      <FlatList
        data={tasks}
        renderItem={renderItem}
        keyExtractor={item => item.id.toString()}
      />
    </View>
  );
};

export default function App() {
  return (
    <Provider store={store}>
      <TaskApp />
    </Provider>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 24, marginBottom: 20 },
  input: { borderWidth: 1, padding: 10, marginBottom: 10 },
  item: { flexDirection: 'row', justifyContent: 'space-between', padding: 10 },
});

Analysis

  • createStore: Creates the Redux store.
  • useSelector/useDispatch: Accesses and modifies state.

Synchronizing State with IPC

main.js

const { app, BrowserWindow, ipcMain } = require('electron');

let tasks = [];

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: './preload.js',
      contextIsolation: true,
      nodeIntegration: false,
    },
  });
  win.loadFile('index.html');
}

app.whenReady().then(createWindow);

ipcMain.handle('sync-tasks', async (event, newTasks) => {
  tasks = newTasks;
  BrowserWindow.getAllWindows().forEach(win => {
    win.webContents.send('tasks-updated', tasks);
  });
  return tasks;
});

ipcMain.on('get-tasks', (event) => {
  event.reply('tasks-updated', tasks);
});

preload.js

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  syncTasks: (tasks) => ipcRenderer.invoke('sync-tasks', tasks),
  onTasksUpdated: (callback) => ipcRenderer.on('tasks-updated', (event, tasks) => callback(tasks)),
  getTasks: () => ipcRenderer.send('get-tasks'),
});

src/store.js

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'ADD_TASK':
      const newTasks = [...state.tasks, action.payload];
      window.electronAPI.syncTasks(newTasks);
      return { ...state, tasks: newTasks };
    case 'REMOVE_TASK':
      const updatedTasks = state.tasks.filter(task => task.id !== action.payload);
      window.electronAPI.syncTasks(updatedTasks);
      return { ...state, tasks: updatedTasks };
    case 'SET_TASKS':
      return { ...state, tasks: action.payload };
    default:
      return state;
  }
};

export default createStore(reducer, applyMiddleware(thunk));

export const loadTasks = () => (dispatch) => {
  window.electronAPI.onTasksUpdated((tasks) => dispatch({ type: 'SET_TASKS', payload: tasks }));
  window.electronAPI.getTasks();
};

src/App.js

import { loadTasks } from './store';

const TaskApp = () => {
  useEffect(() => {
    dispatch(loadTasks());
  }, [dispatch]);
  // Remainder as above
};

Analysis

  • Redux + IPC: State changes are synchronized to the main process.
  • Real-Time Updates: Main process broadcasts state to all windows.

Comprehensive Case Study: Multi-Window Task Manager

Requirement Analysis

Build a multi-window task manager that:

  • Displays a task list in the main window.
  • Adds tasks via a child window.
  • Uses Redux for state management and IPC for synchronization.

Step-by-Step Code Analysis

main.js

const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

let tasks = [];
let mainWindow;

function createMainWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });
  mainWindow.loadFile('index.html');
}

function createAddTaskWindow() {
  const win = new BrowserWindow({
    width: 400,
    height: 300,
    parent: mainWindow,
    modal: true,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });
  win.loadFile('add-task.html');
}

app.whenReady().then(() => {
  createMainWindow();
});

ipcMain.on('open-add-task', () => createAddTaskWindow());

ipcMain.handle('sync-tasks', async (event, newTasks) => {
  tasks = newTasks;
  BrowserWindow.getAllWindows().forEach(win => {
    win.webContents.send('tasks-updated', tasks);
  });
  return tasks;
});

ipcMain.on('get-tasks', (event) => {
  event.reply('tasks-updated', tasks);
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

preload.js

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  openAddTask: () => ipcRenderer.send('open-add-task'),
  syncTasks: (tasks) => ipcRenderer.invoke('sync-tasks', tasks),
  onTasksUpdated: (callback) => ipcRenderer.on('tasks-updated', (event, tasks) => callback(tasks)),
  getTasks: () => ipcRenderer.send('get-tasks'),
});

src/store.js

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const initialState = { tasks: [] };

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'ADD_TASK':
      const newTasks = [...state.tasks, action.payload];
      window.electronAPI.syncTasks(newTasks);
      return { ...state, tasks: newTasks };
    case 'REMOVE_TASK':
      const updatedTasks = state.tasks.filter(task => task.id !== action.payload);
      window.electronAPI.syncTasks(updatedTasks);
      return { ...state, tasks: updatedTasks };
    case 'SET_TASKS':
      return { ...state, tasks: action.payload };
    default:
      return state;
  }
};

export default createStore(reducer, applyMiddleware(thunk));

export const loadTasks = () => (dispatch) => {
  window.electronAPI.onTasksUpdated((tasks) => dispatch({ type: 'SET_TASKS', payload: tasks }));
  window.electronAPI.getTasks();
};

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Task Manager</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 20px; }
    ul { list-style: none; padding: 0; }
    li { padding: 10px; background-color: #f0f0f0; margin-bottom: 5px; display: flex; justify-content: space-between; }
    button { padding: 5px 10px; background-color: #dc3545; color: white; border: none; }
    #add { background-color: #007bff; }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/redux@4/dist/redux.js"></script>
  <script src="https://unpkg.com/react-redux@8/dist/react-redux.js"></script>
  <script src="https://unpkg.com/redux-thunk@2/dist/redux-thunk.js"></script>
  <script src="./src/store.js"></script>
  <script>
    const { Provider, useSelector, useDispatch } = ReactRedux;
    const { useState, useEffect } = React;

    const TaskApp = () => {
      const tasks = useSelector(state => state.tasks);
      const dispatch = useDispatch();

      useEffect(() => {
        dispatch(loadTasks());
      }, [dispatch]);

      const openAddTask = () => {
        window.electronAPI.openAddTask();
      };

      const removeTask = (id) => {
        dispatch({ type: 'REMOVE_TASK', payload: id });
      };

      return React.createElement(
        'div',
        null,
        React.createElement('h1', null, 'Task Manager'),
        React.createElement('button', { id: 'add', onClick: openAddTask }, 'Add Task'),
        React.createElement(
          'ul',
          null,
          tasks.map(task =>
            React.createElement(
              'li',
              { key: task.id },
              task.text,
              React.createElement('button', { onClick: () => removeTask(task.id) }, 'Delete')
            )
          )
        )
      );
    };

    ReactDOM.render(
      React.createElement(Provider, { store: store }, React.createElement(TaskApp)),
      document.getElementById('app')
    );
  </script>
</body>
</html>

add-task.html

<!DOCTYPE html>
<html>
<head>
  <title>Add Task</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 20px; }
    input { width: 100%; padding: 8px; margin-bottom: 10px; border: 1px solid #ccc; }
    button { padding: 8px 16px; background-color: #007bff; color: white; border: none; }
  </style>
</head>
<body>
  <h2>Add New Task</h2>
  <input type="text" id="taskInput" placeholder="Enter task">
  <button id="submit">Submit</button>
  <script>
    const input = document.getElementById('taskInput');
    const submit = document.getElementById('submit');

    submit.addEventListener('click', async () => {
      const task = input.value.trim();
      if (task) {
        await window.electronAPI.syncTasks([...(await window.electronAPI.getTasks() || []), { id: Date.now(), text: task }]);
        window.close();
      }
    });

    input.addEventListener('keypress', (event) => {
      if (event.key === 'Enter') submit.click();
    });
  </script>
</body>
</html>

package.json

{
  "name": "task-manager",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^28.2.2"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-redux": "^8.0.5",
    "redux": "^4.2.0",
    "redux-thunk": "^2.4.2"
  }
}

Running

npm start

Analysis

  • Redux: Manages task state.
  • IPC: Main process synchronizes tasks across all windows.
  • UI: Main window displays the task list, child window adds tasks.

Advanced Techniques and Considerations

Performance Optimization Tips

  • Minimize IPC Calls: Batch state synchronization.
  • Cache State: Avoid redundant requests.
  • Asynchronous Operations: Prevent UI blocking.

Debugging State Management and IPC

  • Redux DevTools:
  const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
  export default createStore(reducer, composeEnhancers(applyMiddleware(thunk)));
  • IPC Logging:
  ipcMain.on('sync-tasks', (event, tasks) => console.log('Syncing:', tasks));
Share your love