All skills
jwynia avatar

/electron-best-practices

@075cb8f
by J Wyniajwynia/agent-skills160 stars
20

Guide AI agents through Electron app development with React including security patterns, type-safe IPC, React integration, packaging with code signing, and testing. Keywords: electron, electron-vite, electron-forge, contextBridge, IPC, security, react, packaging, code signing, notarization, playwright, desktop app.

Use this Skill: https://skilld.dev/gh/jwynia/agent-skills/electron-best-practices

This session only. Nothing lands on disk.

referencesintegrationreact-patterns.md

≈2k tokens on demand. Your agent reads this file only when SKILL.md points to it.

React 18 Integration Patterns for Electron

React 18 runs in Electron's Chromium-based renderer with full access to concurrent features, Suspense, and createRoot. The desktop context adds concerns web apps rarely face: IPC listener lifecycle, long-running processes, multi-window awareness, and error reporting to the main process.


Entry Point and Strict Mode

createRoot works without modification. Always wrap in StrictMode during development -- its double-invocation catches IPC listener leaks that would otherwise accumulate silently in long-running desktop apps.

// renderer/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Concurrent features (useTransition, useDeferredValue, automatic batching) work normally. The renderer is a full Chromium instance with the same JS engine and event loop as Chrome.


IPC Listener Cleanup -- The Critical Pattern

Strict Mode mounts components twice in dev, exposing effects that fail to clean up. In Electron, leaked IPC listeners persist for the app lifetime.

// BAD: Missing cleanup -- duplicates on every re-mount
function CounterDisplay() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    window.electronAPI.onUpdateCounter((value) => setCount(value));
    // No cleanup returned!
  }, []);
  return <div>Count: {count}</div>;
}
// GOOD: Cleanup prevents listener leaks
function CounterDisplay() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const cleanup = window.electronAPI.onUpdateCounter((value) => {
      setCount(value);
    });
    return cleanup; // Strict Mode's double-invoke verifies this works
  }, []);
  return <div>Count: {count}</div>;
}

The preload must return an unsubscribe function from every on-style listener. See Context Isolation for the preload side.

A reusable hook simplifies multi-listener components:

function useIpcListener<T>(
  subscribe: (cb: (value: T) => void) => () => void,
  onValue: (value: T) => void,
  deps: React.DependencyList = []
) {
  useEffect(() => {
    const cleanup = subscribe(onValue);
    return cleanup;
  }, deps);
}

// Usage
function DownloadIndicator() {
  const [progress, setProgress] = useState(0);
  useIpcListener(window.electronAPI.onDownloadProgress, setProgress);
  return <ProgressBar value={progress} />;
}
// Multiple listeners with combined cleanup
function StatusBar() {
  const [online, setOnline] = useState(true);
  const [syncStatus, setSyncStatus] = useState('idle');
  useEffect(() => {
    const c1 = window.electronAPI.onConnectivityChange(setOnline);
    const c2 = window.electronAPI.onSyncStatusChange(setSyncStatus);
    return () => { c1(); c2(); };
  }, []);
  return <footer>{online ? 'Online' : 'Offline'} | Sync: {syncStatus}</footer>;
}

HMR with electron-vite

electron-vite provides Vite-based HMR with React Fast Refresh. Main and preload are rebuilt on change without a full app restart.

// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  main: { plugins: [externalizeDepsPlugin()] },
  preload: { plugins: [externalizeDepsPlugin()] },
  renderer: { plugins: [react()] }, // Enables Fast Refresh
});

Fast Refresh re-runs effects on every save. Correct cleanup means seamless HMR; missing cleanup means listeners double with every save.


Error Boundaries for Desktop

In a web app, errors yield a white screen fixable by refresh. Desktop apps have no refresh -- error boundaries are essential, and they should report to main.

class ElectronErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    window.electronAPI.reportError({
      message: error.message,
      stack: error.stack,
      componentStack: info.componentStack,
    });
  }

  render() {
    if (this.state.hasError) {
      return (
        <ErrorFallback
          error={this.state.error}
          onReset={() => this.setState({ hasError: false })}
        />
      );
    }
    return this.props.children;
  }
}

Use boundaries at multiple levels -- root for catastrophic failures, and feature-level around panels to isolate crashes:

function App() {
  return (
    <ElectronErrorBoundary>
      <Layout>
        <ElectronErrorBoundary fallback={<SidebarFallback />}>
          <Sidebar />
        </ElectronErrorBoundary>
        <ElectronErrorBoundary fallback={<EditorFallback />}>
          <Editor />
        </ElectronErrorBoundary>
      </Layout>
    </ElectronErrorBoundary>
  );
}

Suspense and Lazy Loading

Use React.lazy with Suspense to split heavy components, reducing initial window paint time. Electron loads from disk so the split is fast -- the benefit is less JavaScript to parse before first paint, not less download.

const Settings = React.lazy(() => import('./pages/Settings'));
const Editor = React.lazy(() => import('./pages/Editor'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/settings" element={<Settings />} />
        <Route path="/editor" element={<Editor />} />
      </Routes>
    </Suspense>
  );
}

Window Focus, Lifecycle, and Memory

// Window focus awareness -- throttle work when unfocused
function useWindowFocus(): boolean {
  const [isFocused, setIsFocused] = useState(document.hasFocus());
  useEffect(() => {
    const onFocus = () => setIsFocused(true);
    const onBlur = () => setIsFocused(false);
    window.addEventListener('focus', onFocus);
    window.addEventListener('blur', onBlur);
    return () => {
      window.removeEventListener('focus', onFocus);
      window.removeEventListener('blur', onBlur);
    };
  }, []);
  return isFocused;
}
// Unsaved changes guard
function useBeforeUnload(shouldBlock: () => boolean) {
  useEffect(() => {
    const handler = (e: BeforeUnloadEvent) => {
      if (shouldBlock()) { e.preventDefault(); e.returnValue = ''; }
    };
    window.addEventListener('beforeunload', handler);
    return () => window.removeEventListener('beforeunload', handler);
  }, [shouldBlock]);
}

Memory leak sources in long-running Electron apps:

  1. IPC listeners without cleanup -- most common; always return unsubscribe.
  2. Stale closures in timers -- setInterval capturing old state.
  3. Uncancelled async ops -- use a cancelled flag in effect cleanup.
  4. Large objects in state -- pass file buffers through IPC on demand.
// Cancellable async pattern
function FileLoader({ filePath }: { filePath: string }) {
  const [content, setContent] = useState<string | null>(null);
  useEffect(() => {
    let cancelled = false;
    window.electronAPI.readFile(filePath).then((data) => {
      if (!cancelled) setContent(data);
    });
    return () => { cancelled = true; };
  }, [filePath]);
  return content ? <pre>{content}</pre> : <p>Loading...</p>;
}

Monitor with Chromium DevTools heap snapshots (Ctrl+Shift+I). Growing retained size across snapshots indicates a leak.


See Also

  • State Management -- Zustand, electron-store, and cross-window state synchronization patterns.
  • Multi-Window State -- Architecture for managing state across multiple Electron windows.
  • Context Isolation -- Preload script patterns that enable the cleanup functions used by React effects.
  • Typed IPC -- Type-safe IPC channel definitions that pair with the React hooks shown in this reference.

Source: SKILL.md on GitHub

2 warnings16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides comprehensive guidance, templates, reference scripts, and architectural configurations for developing secure Electron applications with React. All components adhere strictly to security standards, and all external tools, scripts, and configurations are handled using safe practices without any malicious or suspicious patterns.

  • Socket16d

    1 alert: gptSecurity

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    35/35 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 075cb8f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Dormantupdated 8 months ago
Other metadata
compatibility
Requires Deno for analysis scripts. Applicable to any Electron project using TypeScript and React.
metadata
{
  "author": "agent-skills",
  "version": "1.0",
  "domain": "development",
  "type": "utility",
  "mode": "assistive"
}

README badge

README badge for jwynia/agent-skills/electron-best-practices