Declarative React components for Electron window management. Opens native windows with <Window open>, renders children via portals so your React context (providers, themes, state) works inside child windows without any extra wiring.
npm install @loc/electron-windowThree 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({
/* ... */
});
manager.setupForWindow(mainWindow);
mainWindow.loadFile("index.html");
});| Option | Type | Default | Description |
|---|---|---|---|
defaultWindowOptions |
BrowserWindowConstructorOptions | () => BrowserWindowConstructorOptions |
{} |
Applied to every child window. Use a function for dynamic values (e.g. theme-aware backgroundColor). |
allowedOrigins |
string[] |
unset (allow) | Restrict which parent renderer origins may use this library's IPC. ["*"] explicitly allows all. |
devWarnings |
boolean |
true in dev |
Log warnings for misuse (blocked props, shape changes, etc.). |
maxPendingWindows |
number |
100 |
Max windows awaiting creation. Prevents runaway open loops. |
maxWindows |
number |
50 |
Max total open windows. Registrations beyond this are rejected. |
debug |
boolean |
false |
Log every IPC call and event to the console. |
// preload.ts — must be bundled (esbuild, webpack, etc.)
import "@loc/electron-window/preload";// 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>
);
}Children of <Window> are in the parent React tree. Redux stores, theme providers, routers — they all work inside child windows automatically.
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
required | Whether the window exists. false destroys it. |
visible |
boolean |
true |
Show/hide without destroying. State is preserved. |
closable |
boolean |
true |
Whether the user can close the window. |
onUserClose |
() => void |
— | User clicked X. Fires once; sync your state here. |
onClose |
() => void |
— | Window destroyed (any reason: user, programmatic, unmount). |
onReady |
() => void |
— | Window ready and content mounted. |
open controls existence. visible controls visibility. open={true} visible={false} creates a hidden window with state preserved. open={false} destroys everything.
| Prop | Type | Description |
|---|---|---|
defaultWidth / defaultHeight |
number |
Initial size. Applied once on creation. User can resize freely. |
defaultX / defaultY |
number |
Initial position. Applied once on creation. |
width / height |
number |
Controlled size. Changes resize the window. Use with onBoundsChange. |
x / y |
number |
Controlled position. Changes move the window. |
onBoundsChange |
(bounds) => void |
Fires on resize/move. Debounced internally (100ms). |
minWidth / maxWidth |
number |
Size constraints. |
minHeight / maxHeight |
number |
Size constraints. |
center |
boolean |
Center on creation. Default true when no position specified. |
The default* / controlled split follows React's defaultValue / value pattern. Use defaultWidth for fire-and-forget, width + onBoundsChange for two-way sync.
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string |
"" |
Window title. |
transparent |
boolean |
false |
Transparent background. Creation-only. |
frame |
boolean |
true |
Show window chrome. Creation-only. |
titleBarStyle |
string |
— | "hidden", "hiddenInset", etc. Creation-only. |
vibrancy |
string |
— | macOS vibrancy effect. Creation-only. |
backgroundColor |
string |
— | Background color. |
opacity |
number |
— | Window opacity (0.0–1.0). |
Creation-only props can't change after the window is created. Changing them logs a dev warning. Set recreateOnShapeChange to destroy and recreate the window instead.
| Prop | Type | Default |
|---|---|---|
resizable |
boolean |
true |
movable |
boolean |
true |
minimizable |
boolean |
true |
maximizable |
boolean |
true |
focusable |
boolean |
true |
alwaysOnTop |
boolean | AlwaysOnTopLevel |
false |
skipTaskbar |
boolean |
false |
fullscreen |
boolean |
false |
fullscreenable |
boolean |
true |
showInactive |
boolean |
false |
ignoreMouseEvents |
boolean |
false |
visibleOnAllWorkspaces |
boolean |
false |
AlwaysOnTopLevel: "normal" | "floating" | "torn-off-menu" | "modal-panel" | "main-menu" | "status" | "pop-up-menu" | "screen-saver". Higher levels float above lower ones. true behaves like "floating".
| Prop | Fires when |
|---|---|
onFocus |
Window gains focus |
onBlur |
Window loses focus |
onMaximize / onUnmaximize |
Maximize state changes |
onMinimize / onRestore |
Minimize state changes |
onEnterFullscreen / onExitFullscreen |
Fullscreen changes |
onDisplayChange |
Window moves to a different monitor |
onBoundsChange |
Window resized or moved |
| Prop | Platform | Description |
|---|---|---|
trafficLightPosition |
macOS | { x, y } for close/minimize/maximize buttons |
titleBarOverlay |
Windows | { color, symbolColor, height } |
targetDisplay |
all | "primary", "cursor", or display index. Centers the window on that display when no explicit x/y. |
| Prop | Type | Default | Description |
|---|---|---|---|
persistBounds |
string |
— | Unique key. Saves bounds to localStorage, restores on reopen. |
recreateOnShapeChange |
boolean |
false |
Recreate window when creation-only props change. |
name |
string |
— | Debug label for DevTools and warning messages. |
injectStyles |
"auto" | false | (doc) => void |
"auto" |
How to copy styles into the child window. false for CSS-in-JS. |
All hooks must be called inside a <Window>'s children.
function WindowContent() {
// Imperative handle — stable callbacks, state as snapshot
const win = useCurrentWindow();
win.focus();
win.close();
win.setBounds({ width: 800, height: 600 });
// Reactive state — each re-renders only when its value changes
const isFocused = useWindowFocused();
const isMaximized = useWindowMaximized();
const isMinimized = useWindowMinimized();
const isFullscreen = useWindowFullscreen();
const isVisible = useWindowVisible();
const bounds = useWindowBounds(); // { x, y, width, height }
const display = useWindowDisplay(); // DisplayInfo | null
const state = useWindowState(); // WindowState | null
const doc = useWindowDocument(); // child window's Document — for UI lib portal containers
}useCurrentWindow() returns a WindowHandle. The method references (focus, close, etc.) are stable across renders — safe to pass as effect deps. The handle object itself changes when window state changes (to reflect isFocused, bounds, etc.), so don't use the whole handle as a dep.
// Simple — just add a key
<Window
open={show}
persistBounds="settings"
defaultWidth={600}
defaultHeight={400}
>
<Settings />
</Window>First open uses defaults. User resizes/moves, bounds save to localStorage. Next open restores them.
For manual control, use the hook directly:
import { usePersistedBounds } from "@loc/electron-window";
function PersistentWindow({ children }) {
const { bounds, save, clear } = usePersistedBounds("my-window", {
defaultWidth: 800,
defaultHeight: 600,
});
return (
<Window open {...bounds} onBoundsChange={save}>
{children}
<button onClick={clear}>Reset Position</button>
</Window>
);
}For windows that appear/disappear frequently (overlays, HUDs, menus), pool pre-warms hidden windows for instant display:
import { PooledWindow, createWindowPool } from "@loc/electron-window";
// Create once at module level
const overlayPool = createWindowPool(
{ transparent: true, frame: false }, // shape (creation-only props)
{ minIdle: 1, maxIdle: 3, idleTimeout: 30000 }, // pool config
{ injectStyles: "auto" }, // optional: "auto" | false | (doc) => void
);
function App() {
const [show, setShow] = useState(false);
return (
<PooledWindow pool={overlayPool} open={show} alwaysOnTop>
<Overlay />
</PooledWindow>
);
}On open={true}: acquires a pre-warmed window from the pool (instant). On open={false}: hides and returns to pool (no destroy/recreate cost).
Shape props (transparent, frame, titleBarStyle, vibrancy) and injectStyles are fixed by the pool definition. Most other props work per-use: defaultWidth/defaultHeight size the window on each acquire, and behavior props (alwaysOnTop, opacity, etc.) update live while open. targetDisplay, persistBounds, and recreateOnShapeChange are not accepted on <PooledWindow> (TypeScript error) — pool windows are pre-created and reused, so these don't fit the model.
Full pooling guide → — pool lifetime, destroyWindowPool, HMR handling, and the close-button behavior difference vs <Window>.
<Window
open={showEditor}
closable={!hasUnsavedChanges}
onUserClose={() => setShowEditor(false)}
>
<Editor />
{hasUnsavedChanges && <SavePrompt />}
</Window>const ref = useRef<WindowRef>(null);
<Window ref={ref} open={show}>
<Content />
</Window>;
// Later
ref.current?.focus();
ref.current?.setBounds({ width: 1024, height: 768 });<Window open={showA} title="Window A">
<ContentA />
</Window>
<Window open={showB} title="Window B">
<ContentB />
</Window>Each <Window> manages its own lifecycle independently.
import {
MockWindowProvider,
MockWindow,
getMockWindows,
resetMockWindows,
simulateMockWindowEvent,
} from "@loc/electron-window/testing";
// Test window management
test("opens settings window", async () => {
resetMockWindows();
render(
<MockWindowProvider>
<MyApp />
</MockWindowProvider>,
);
fireEvent.click(screen.getByText("Open Settings"));
await waitFor(() => {
expect(getMockWindows()).toHaveLength(1);
expect(getMockWindows()[0].props.title).toBe("Settings");
});
});
// Test components that use useCurrentWindow()
test("shows focused indicator", () => {
render(
<MockWindow state={{ isFocused: true }}>
<StatusBar />
</MockWindow>,
);
expect(screen.getByText("Focused")).toBeInTheDocument();
});
// Simulate events
test("handles bounds change", async () => {
resetMockWindows();
render(
<MockWindowProvider>
<MyApp />
</MockWindowProvider>,
);
// ... open window ...
simulateMockWindowEvent(getMockWindows()[0].id, {
type: "boundsChanged",
bounds: { x: 0, y: 0, width: 500, height: 400 },
});
});Child windows portal DOM across documents — it's easy to accidentally retain
a closed window via an event listener, ref, or closure. createLeakTester
asserts that windows opened during a block were actually garbage-collected
after close:
import { createLeakTester } from "@loc/electron-window/testing";
test("closing the settings window releases it", async () => {
const leaks = createLeakTester();
await leaks.track(async () => {
await openSettingsWindow();
await closeSettingsWindow();
});
await leaks.expectNoLeaks(); // throws if the child Window is still reachable
});Run your test process with --expose-gc (Node) or --js-flags=--expose-gc
(Electron) so expectNoLeaks() can force a collection. Without it the check
is best-effort and may false-pass.
Automatic detection in dev: when gc is exposed, closing a window also
schedules a background check — if the window hasn't been collected ~5s later,
an error is logged with debugging hints. Additionally, useWindowDocument()
wraps the returned Document in a Proxy that warns on any access after the
window closes, printing the stack where it was originally acquired.
webPreferencescannot be set from the renderer — only viasetupWindowManagerin the main process. The library enforcesnodeIntegration: false,contextIsolation: true, andsandbox: true(default) on all child windows regardless of consumer config.- All renderer-supplied props are filtered through an allowlist before reaching
BrowserWindow - Child windows can only open
about:blank— arbitrary URLs are rejected - IPC main-frame-only enforcement in the generated IPC layer (iframes cannot call the API)
- Per-WebContents ownership: a renderer can only mutate (
UpdateWindow/DestroyWindow/WindowAction) windows it registered. If yousetupForWindowon multiple parent windows, each is isolated. - Rate limits on window creation (
maxPendingWindows,maxWindows, 10-second TTL on pending registrations) - Window IDs are crypto-random (
crypto.randomUUID())
Two ways to restrict which renderer origins can use the library:
Runtime (main process only) — works whether or not you bundle your main process:
setupWindowManager({
allowedOrigins: ["app://main", "file://"],
});Build-time (main + preload) — if you bundle both your main process and preload (most apps do), define a constant in your bundler config. This additionally gates the preload: on a wrong origin, window.electron_window is never exposed at all.
// vite.config.ts / esbuild / webpack DefinePlugin — for BOTH main and preload builds
define: {
__ELECTRON_WINDOW_ALLOWED_ORIGINS__: JSON.stringify(["app://main", "file://"]),
}Both mechanisms validate the same thing (the main frame's origin, since iframes are already blocked). Use the build-time define for the extra preload-side gate; use the runtime config if you don't bundle your main process.
| Import | Use |
|---|---|
@loc/electron-window |
Components, hooks (renderer) |
@loc/electron-window/main |
setupWindowManager (main process) |
@loc/electron-window/preload |
IPC bridge (preload script) |
@loc/electron-window/testing |
Mocks for unit tests |
MIT