Skip to content

Getting Started

Terminal window
npm install @loc/electron-window

<Window open> in your React tree calls window.open(). The main process intercepts that via setWindowOpenHandler, creates a BrowserWindow with the props you pre-registered over IPC, and hands back a blank document. React then portals your children into that document — so they stay in the parent React tree (your context providers keep working), but their DOM lives in the child window.

Understanding this helps with the edge cases: window in your component code is the parent renderer’s window (React runs there), but element.ownerDocument is the child’s document (DOM lives there). See Patterns → Integration Notes when you hit this.

Three files — one per Electron process.

main.ts
import path from "node:path";
import { app, BrowserWindow } from "electron";
import { setupWindowManager } from "@loc/electron-window/main";
const manager = setupWindowManager({
defaultWindowOptions: {
webPreferences: {
preload: path.join(__dirname, "preload.js"),
},
},
});
app.whenReady().then(() => {
const mainWindow = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, "preload.js") },
});
manager.setupForWindow(mainWindow);
mainWindow.loadFile("index.html");
});

setupWindowManager is the only place where webPreferences can be configured. The renderer cannot override them. See Security for details.

setupForWindow accepts a BrowserWindow, WebContentsView, or bare WebContents — useful if your parent renderer is a WebContentsView rather than a top-level window.

// preload.ts — must be bundled (esbuild, webpack, etc.)
import "@loc/electron-window/preload";

This sets up the IPC bridge between the main and renderer processes.

// renderer
import { useState } from "react";
import { WindowProvider, Window } from "@loc/electron-window";
function App() {
const [showSettings, setShowSettings] = useState(false);
return (
<WindowProvider>
<button onClick={() => setShowSettings(true)}>Settings</button>
<Window
open={showSettings}
onUserClose={() => setShowSettings(false)}
title="Settings"
defaultWidth={600}
defaultHeight={400}
>
<SettingsPanel />
</Window>
</WindowProvider>
);
}

WindowProvider must wrap all <Window> usage. Children of <Window> live in the parent React tree — Redux stores, theme providers, routers, and context all work inside child windows without extra wiring.

PropTypeDefaultDescription
devWarningsbooleantrue when NODE_ENV==="development"Enable dev warnings. Set explicitly in sandboxed renderers where process.env isn’t available.
debugbooleanfalseLog every IPC call and event to the console.

In a sandboxed renderer (the library’s default), process.env.NODE_ENV is undefined — dev warnings won’t fire unless you set <WindowProvider devWarnings> explicitly.

By default, the library denies any window.open() from the parent renderer that isn’t an about:blank managed window. This means <a target="_blank"> silently fails. To route external URLs to the system browser, pass a fallback handler:

import { shell } from "electron";
manager.setupForWindow(mainWindow, {
fallbackWindowOpenHandler: ({ url }) => {
if (url.startsWith("https://")) shell.openExternal(url);
return { action: "deny" };
},
});

Without this, a dev warning fires: "window.open to <url> denied. Not a managed window and no fallbackWindowOpenHandler provided."

The same applies to window.open() calls from inside a managed child window (a <a target="_blank"> link in a popout). The library installs its own setWindowOpenHandler on every child — by default it denies everything. Pass childWindowOpenHandler to handle these, and onChildWindowCreated for anything else you need to attach (context menu, devtools):

manager.setupForWindow(mainWindow, {
childWindowOpenHandler: ({ url }) => {
if (url.startsWith("https://")) shell.openExternal(url);
return { action: "deny" };
},
onChildWindowCreated: (win) => {
win.webContents.on("context-menu", (_e, params) => showSpellcheckMenu(win, params));
},
});

Don’t try to do this from your own webContents.on("did-create-window") listener — that is the same event the library uses to install its handler, and which one wins depends on registration order and event-loop timing. childWindowOpenHandler and onChildWindowCreated run after the library has wired the child, so there’s no race.

When the owner WebContents performs a hard main-frame navigation (reload, loadURL(), full page nav), the library destroys that owner’s managed child windows. Without this, a reload orphans the popouts: they stay on screen with stale content while the React tree that was portaling into them is gone.

The reaper does not fire on SPA pushState navigations or iframe loads — only hard navs. If your owner page re-adopts existing popouts after a reload, opt out:

manager.setupForWindow(mainWindow, { reapOnOwnerNavigate: false });

setupWindowManager returns (and getWindowManager() retrieves) a WindowManager instance. Useful for tray menus, “close all windows”, or anything else in the main process that needs to touch child windows:

import { app } from "electron";
import { getWindowManager } from "@loc/electron-window/main";
const manager = getWindowManager();
// All managed child windows
for (const win of manager.getAllWindows()) {
win.focus(); // or .close(), .minimize(), .destroy(), ...
const state = win.getState(); // { id, bounds, isFocused, isVisible, ... }
}
// One window by ID
const instance = manager.getWindow(someId);
instance?.window?.setBounds({ width: 800, height: 600 }); // raw BrowserWindow
// O(1) "is this BrowserWindow one of yours?" — handy in app.on("browser-window-focus") etc.
app.on("browser-window-focus", (_e, win) => {
if (manager.owns(win)) markPopoutActive(win);
});

WindowInstance wraps a BrowserWindow with the library’s lifecycle methods. instance.window gives you the underlying Electron object if you need something the wrapper doesn’t expose.

hideOnClose pool windows veto their own close event. app.quit() waits for all BrowserWindows to drain — a window that vetoes its own close stalls the quit forever. The library disarms its windows on app.on("before-quit") automatically (and re-arms them via cancelQuit() if another listener vetoes the quit), so plain app.quit(), the macOS Cmd-Q path, and unsaved-changes-prompt-then-cancel all Just Work. On Windows, session-end (logoff/shutdown) is handled per-window inside the library too.

The one gap: electron-updater’s autoUpdater.quitAndInstall() emits before-quit-for-update on its own emitter, not Electron’s app. Call prepareForQuit() yourself there:

import { autoUpdater } from "electron-updater";
autoUpdater.on("before-quit-for-update", () => {
getWindowManager()?.prepareForQuit();
});

Window doesn’t appear / nothing happens:

  • <WindowProvider> wrapping your app? <Window> won’t mount without it.
  • Preload bundled? The import is side-effect-only — most bundlers need sideEffects in package.json or explicit inclusion. Check the renderer console for “preload bridge not found”.
  • webPreferences.contextIsolation: true? The contextBridge API requires it. The “preload bridge not found” warning lists this as a possible cause.
  • manager.setupForWindow(mainWindow) called before loadFile/loadURL? The setWindowOpenHandler needs to be registered before the renderer runs.

“hit maxWindows (50)” with no obvious cause: Usually a <PooledWindow> with an unstable pool prop (inline createWindowPool(), useMemo with changing deps, or HMR without destroyWindowPool in import.meta.hot.dispose). See Pooling → Pool lifetime.

Styles missing in child window: injectStyles: "auto" copies <style>/<link> tags from the parent’s <head>. CSS-in-JS frameworks that inject styles elsewhere need injectStyles: false + their own injection logic.

ImportUse
@loc/electron-windowComponents and hooks (renderer)
@loc/electron-window/mainsetupWindowManager (main process)
@loc/electron-window/preloadIPC bridge (preload script)
@loc/electron-window/testingMocks for unit tests
  • Props Reference — all <Window> props
  • Hooks — reactive window state
  • Pooling — pre-warm windows for instant display
  • Persistence — save/restore bounds across sessions
  • Testing — mock providers and event simulation
  • Security — origin allowlist, ownership, enforced webPreferences
  • Patterns — common recipes and integration notes