iTranslated by AI
[elecxzy] How to Intercept the OS Close Button Event in Electron
Implementing "Save Changes?" in Electron - The elecxzy Implementation for Checking Renderer State and Exiting Safely
When building a Single Page Application (SPA) text editor like elecxzy (currently under development) with Electron, a challenge you'll inevitably face is "controlling the behavior when a user tries to close the window via the close button while there is unsaved data."
In Electron, the main process controls the window's close button (the 'X' button), but the state of whether a buffer is "dirty" (unsaved) exists within the state of the renderer process (React, etc.).
In this article, I'll summarize the "standard approach" for crossing this process barrier and implementing a safe confirmation flow that even works with OS-level exit operations.

1. The Challenge: The Main Process Doesn't Know if "Saving is Required"
By default, in Electron, when a window's close event occurs, the window is destroyed immediately. However, the renderer process is what manages the buffer state (such as a modified flag).
Instead of simply trying to close the window from the main process side, you need a workflow that "delegates the confirmation to the renderer."
2. Implementation Overview
The implementation consists of the following three steps. The key is to temporarily cancel the OS event and wait for "permission" from the renderer.
-
Main Process: Intercept the
closeevent, pause the exit (preventDefault), and query the renderer. - Renderer Process: Check the buffer state and, if necessary, show a dialog (or minibuffer) to the user.
- Renderer -> Main: Once the decision to save or discard has been made, notify the main process that "it's okay to close (resume)."
3. Implementation Code
① Main Process: Intercepting the Exit (e.g., main/index.ts)
In the main process, we use a flag to manage whether the exit has been "confirmed."
// Flag indicating whether the application is ready to quit
let isReadyToQuit = false;
// 3. Receive "permission to quit" from the renderer
ipcMain.on('confirm-quit', () => {
isReadyToQuit = true;
// Calling close() again here will trigger the event listener below once more
if (mainWindow) mainWindow.close();
});
mainWindow.on('close', (e) => {
// 1. If not ready to quit yet
if (!isReadyToQuit) {
// Prevent the window from closing immediately
e.preventDefault();
// 2. Query the renderer: "I want to close, is that okay?"
mainWindow?.webContents.send('app-close-request');
return;
}
// 4. If isReadyToQuit is true, it passes through here and the normal exit process occurs
});
② Preload: Secure Communication Path (e.g., preload/index.ts)
We use contextBridge to enable the renderer to communicate with the main process.
contextBridge.exposeInMainWorld('electronAPI', {
// Subscribe to queries from the main process
onAppCloseRequest: (callback: () => void) => {
ipcRenderer.on('app-close-request', callback);
return () => ipcRenderer.removeListener('app-close-request', callback);
},
// Notify the main process that confirmation is complete
confirmQuit: () => ipcRenderer.send('confirm-quit')
});
③ Renderer Process: Executing the Confirmation Flow (e.g., App.tsx)
Monitor requests from the main process in a React component or similar, and reuse your existing "exit confirmation logic."
useEffect(() => {
// Monitor the "Is it okay to close?" request from the main process
if (window.electronAPI.onAppCloseRequest) {
const unsubscribe = window.electronAPI.onAppCloseRequest(() => {
// Start the app-specific exit confirmation process here
handleQuitWithConfirmation();
});
return () => unsubscribe();
}
}, [handleQuitWithConfirmation]);
// Exit confirmation logic
const handleQuitWithConfirmation = () => {
// If there are no unsaved buffers
if (modifiedBuffers.length === 0) {
// Permit immediate exit and notify the main process
window.electronAPI.confirmQuit();
return;
}
// If there are unsaved changes:
// Prompt the user via a minibuffer, etc. (Save file ...? y/n)
// Call confirmQuit() after all saving processes are completed
// * Internally, check all buffers using recursive list traversal or Promises
promptSaveParams().then((result) => {
if (result === 'saved' || result === 'discarded') {
window.electronAPI.confirmQuit();
}
// Do nothing if cancelled (the window remains open)
});
};
4. Key Point: Unified UX
The biggest advantage of this approach is that both the "OS close button" and "in-app shortcuts (e.g., C-x C-c)" go through the exact same function (logic).
In elecxzy, the Emacs-like editor I'm currently developing, I was able to show the familiar Emacs minibuffer confirmation interface—Save file ...? (y, n, !, .)—even for OS window close operations, rather than a standard OS dialog. This provides a consistent user experience regardless of how the application is closed.
Summary
- Stop the main process exit using
event.preventDefault(). - Use IPC to check the renderer's state (buffer dirty flags).
- Once confirmed, notify the main process via IPC and call
close()again.
By following this "standard pattern," even editors built as SPAs (Single Page Applications) can achieve the safe exit behavior expected of a desktop application.
Discussion