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."

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 { 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

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.

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