Skip to content

Instantly share code, notes, and snippets.

@ccorcos
Created October 2, 2025 17:29
Show Gist options
  • Select an option

  • Save ccorcos/4c73c328189252a96797e5cdae8a9cec to your computer and use it in GitHub Desktop.

Select an option

Save ccorcos/4c73c328189252a96797e5cdae8a9cec to your computer and use it in GitHub Desktop.
Electron Window Manager using React pattern

Think of ElectronApp as a React Component. It take your state and transforms it into window elements, similar to JSX elements.

Think of ElectronWindowManager as ReactDOM which takes the window elements and reconciles them with the existing state.

The DOM in this case is just the list of window refs available at manager.windows.

import * as path from "path"
import { WindowElm } from "./ElectronWindowManager"
import StateMachine from "./StateMachine"
export const htmlPath = path.join(__dirname, "..", "renderer", "index.html")
export const preloadPath = path.join(__dirname, "..", "preload", "preload.js")
/**
* ElectronApp is the "component" that transforms MainState into WindowElm elements.
* This is analogous to a React component that returns JSX.
* It creates the declarative representation of what windows should exist and how they should behave.
*/
export function ElectronApp(app: StateMachine<State>): WindowElm[] {
return app.state.windows.map((windowState) => {
const { id, focused, rect } = windowState
const elm: WindowElm = {
id,
focused,
rect,
args: {
webPreferences: { preload: preloadPath },
},
onCreate: (browserWindow) => {
// Load the HTML file or dev server
if (process.env.NODE_ENV === "development" || process.env.ELECTRON_IS_DEV) {
browserWindow.loadURL("http://localhost:5173")
} else {
browserWindow.loadFile(htmlPath)
}
// Listen to IPC messages...
},
onMove: (browserWindow) => {
const [x, y] = browserWindow.getPosition()
const currentWindowState = app.state.windows.find((w) => w.id === id)
const currentRect = currentWindowState?.rect
if (currentRect && currentRect.left === x && currentRect.top === y) return
app.dispatch.moveWindow(id, { x, y })
},
onResize: (browserWindow) => {
const [width, height] = browserWindow.getSize()
const currentWindowState = app.state.windows.find((w) => w.id === id)
const currentRect = currentWindowState?.rect
if (currentRect && currentRect.width === width && currentRect.height === height) return
app.dispatch.resizeWindow(id, { width, height })
},
onFocus: (browserWindow) => {
setTimeout(() => {
const currentWindowState = app.state.windows.find((w) => w.id === id)
if (currentWindowState && !currentWindowState.focused) {
app.dispatch.focusWindow(id)
}
})
},
}
return elm
})
}
import { BrowserWindow } from "electron"
import { differenceBy, intersectionBy } from "lodash"
/**
* WindowElm is the declarative representation of a window, similar to JSX elements in React.
* It describes what the window should look like and how it should behave.
*/
export type WindowElm = {
id: string
focused: boolean
rect: { left: number; top: number; width: number; height: number }
/** Arguments passed to BrowserWindow constructor */
args?: Electron.BrowserWindowConstructorOptions
/** Called when the window is first created */
onCreate: (browserWindow: BrowserWindow) => void
/** Called when the window is moved */
onMove: (browserWindow: BrowserWindow) => void
/** Called when the window is resized */
onResize: (browserWindow: BrowserWindow) => void
/** Called when the window gains focus */
onFocus: (browserWindow: BrowserWindow) => void
/** Called when the system attempts to close the window */
onClose: (browserWindow: BrowserWindow, event: Electron.Event) => void
}
/**
* WindowRef is the "reified" version of WindowElm - it includes both the element
* and the actual BrowserWindow instance, similar to DOM nodes in React.
*/
export type WindowRef = {
elm: WindowElm
browserWindow: BrowserWindow
}
/**
* ElectronWindowManager manages the lifecycle of BrowserWindows based on declarative WindowElm descriptions.
* It performs reconciliation similar to React's virtual DOM reconciliation.
* This is analogous to ReactDOM - it renders declarative elements into actual browser windows.
*/
export class ElectronWindowManager {
refs: Map<string, WindowRef> = new Map()
get windows(): WindowRef[] {
return Array.from(this.refs.values())
}
render(nextElements: WindowElm[]) {
const prevElements = Array.from(this.refs.values()).map((ref) => ref.elm)
// Diff the elements to determine what changed
const toCreate = differenceBy(nextElements, prevElements, (elm) => elm.id)
const toDestroy = differenceBy(prevElements, nextElements, (elm) => elm.id)
const toUpdate = intersectionBy(nextElements, prevElements, (elm) => elm.id)
// Destroy windows that no longer exist
for (const elm of toDestroy) {
const ref = this.refs.get(elm.id)
if (ref) {
ref.browserWindow.destroy()
this.refs.delete(elm.id)
}
}
// Update existing windows
for (const elm of toUpdate) {
const ref = this.refs.get(elm.id)
if (ref) {
this.updateWindow(ref, elm)
}
}
// Create new windows
for (const elm of toCreate) {
const ref = this.createWindow(elm)
this.refs.set(elm.id, ref)
}
}
/**
* Create a new window from an element
*/
private createWindow(elm: WindowElm): WindowRef {
const { rect, args = {} } = elm
const browserWindow = new BrowserWindow({
...args,
...rect,
})
// Set up event listeners
const moveHandler = () => {
elm.onMove(browserWindow)
}
const resizeHandler = () => {
elm.onResize(browserWindow)
}
const focusHandler = () => {
elm.onFocus(browserWindow)
}
const closeHandler = (event: Electron.Event) => {
elm.onClose(browserWindow, event)
}
browserWindow.on("move", moveHandler)
browserWindow.on("resize", resizeHandler)
browserWindow.on("focus", focusHandler)
browserWindow.on("close", closeHandler)
// Call onCreate lifecycle hook
elm.onCreate(browserWindow)
return { elm, browserWindow }
}
/**
* Update an existing window with new element properties
*/
private updateWindow(ref: WindowRef, nextElm: WindowElm) {
const prevElm = ref.elm
const { browserWindow } = ref
// Update focus
if (nextElm.focused && !browserWindow.isFocused()) {
browserWindow.focus()
}
// Update rect
if (prevElm.rect !== nextElm.rect) {
const prevRect = prevElm.rect
const nextRect = nextElm.rect
if (prevRect.left !== nextRect.left || prevRect.top !== nextRect.top) {
browserWindow.setPosition(nextRect.left, nextRect.top, false)
}
if (prevRect.height !== nextRect.height || prevRect.width !== nextRect.width) {
browserWindow.setSize(nextRect.width, nextRect.height, false)
}
}
// Update the element reference
ref.elm = nextElm
}
destroy() {
for (const ref of this.refs.values()) {
ref.browserWindow.destroy()
}
this.refs.clear()
}
}
const app = new StateMachine(initialState)
const manager = new ElectronWindowManager()
manager.render(ElectronApp(app))
mainApp.addListener(() => {
manager.render(ElectronApp(app))
})
import { mapValues } from "lodash"
type TupleRest<T extends unknown[]> = T extends [any, ...infer U]
? U
: never;
export type AnyReducers<S> = { [key: string]: (state: S, ...args: any[]) => S }
export type Actions<R extends AnyReducers<any>> = {
[K in keyof R]: { fn: K; args: TupleRest<Parameters<R[K]>> }
}[keyof R]
export type Dispatcher<R extends AnyReducers<any>, O = void> = {
[K in keyof R]: (...args: TupleRest<Parameters<R[K]>>) => O
}
export class StateMachine<S, R extends AnyReducers<S>> {
constructor(
public state: S,
private reducers: R
) {}
private onDispatches = new Set<(action: Actions<R>) => void>()
// Override this function to log or pipe actions elsewhere.
public onDispatch(fn: (action: Actions<R>) => void) {
this.onDispatches.add(fn)
return () => this.onDispatches.delete(fn)
}
public dispatchAction(action: Actions<R>) {
this.actions.push(action)
this.onDispatches.forEach((fn) => fn(action))
if (!this.running) {
this.running = true
this.flush()
}
}
// Using a Proxy so that you can cmd-click on a dispatched action to find the reducer.
public dispatch = (() => {
const self = this
return new Proxy(
{},
{
get(target, fn: any, receiver) {
return (...args: any[]) => self.dispatchAction({ fn, args } as any)
},
}
)
})() as Dispatcher<R>
private running = false
private actions: Actions<R>[] = []
private flush() {
if (this.actions.length === 0) {
this.running = false
this.listeners.forEach((fn) => fn())
return
}
const action = this.actions.shift()!
const prevState = this.state
this.state = this.reducers[action.fn](prevState, ...action.args)
this.flush()
}
private listeners = new Set<() => void>()
/**
* Listener is called after all dispatches are processed. Make sure you use the
* plugin argument if you want to compare with previous state for an effect.
*/
public addListener(listener: () => void) {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment