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. The function form replaces auto mirroring entirely. |
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.
Each pooled window is its own browsing-context realm — it has its own customElements registry, its own prototypes, its own document. Code that registers globals on the parent window (e.g. a library calling customElements.define(...) at import time) won't be visible inside a pooled window.
onWindowSetup is a per-window hook that runs once for each window the pool creates — including pre-warmed idle windows — right after the document is initialized (<base>, styles, and the #root container are in place) and before React portals any content in. That ordering matters: custom elements registered here upgrade synchronously as React inserts them, so there's no flash of unupgraded content the way a useLayoutEffect (which runs after mount) would risk.
const popoutPool = createWindowPool(
{ frame: true },
{ minIdle: 1 },
{
// injectStyles stays "auto" — onWindowSetup is additive, not a replacement
onWindowSetup: (childWindow, doc) => {
// Register custom elements on the child realm's registry
if (!childWindow.customElements.get("my-widget")) {
childWindow.customElements.define("my-widget", class extends childWindow.HTMLElement {});
}
// Sync a documentElement attribute from the parent
doc.documentElement.dataset.theme = document.documentElement.dataset.theme;
// Optional cleanup, runs when the window is destroyed (pool teardown,
// idle eviction, or external close — not on hide/release back to pool).
return () => {
/* tear down listeners, observers, etc. */
};
},
},
);This is the sanctioned home for "auto styles plus a bit more per-window setup." The function form of injectStyles replaces the auto <style>/<link> mirroring — use it only when you want full control over style injection. onWindowSetup runs regardless of injectStyles mode.
The hook must be synchronous. An async hook's rejection escapes the error guard and its returned cleanup is silently dropped — TypeScript won't warn (its void return rule is permissive), but the library detects it at runtime and logs an error. Do dynamic imports at module scope; keep the hook itself sync.
What survives across pool reuse. The hook runs once per window lifetime, not once per use. A pooled window is acquired and released many times before it's destroyed, and release() resets body.className, doc.title, clears #root's contents, and removes any other <body> children (e.g. portal mount points appended directly to body) between uses to prevent state leaking between consumers. Mutations to those will be silently reverted after the first reuse cycle. Mutations that do persist: realm globals (customElements registrations, prototype patches), documentElement attributes, and <head> contents (the style observer manages those separately). Stick to those in onWindowSetup; anything per-acquire belongs in your component's effect.
For non-pooled <Window>, you don't need onWindowSetup — the window's lifetime matches your component's, so a useWindowDocument() + useLayoutEffect does the same job at the same time.
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