Electron Adventures: Episode 14: React

taw

Tomasz Wegrzanowski

Posted on August 7, 2021

Electron Adventures: Episode 14: React

In previous episode, I showed how to setup Electron project with Svelte + rollup frontend. This time we'll do the same with React + webpack.

And again, we'll do it by creating a React app first, and connecting Electron to it as a second step; not the other way around.

Create a React app

We start the usual way, by creating a new React app, and removing all the crap we don't need.

In fact the default template contains so much crap we don't need, I'm going to use another template.

$ npx create-react-app episode-14-react --use-npm --template ready
Enter fullscreen mode Exit fullscreen mode

If you're into React, you might have a favorite template already, and you can use it instead. Pretty much all of them will work with Electron just fine.

Disable browser auto-open

We need to do one thing. React has annoying habit of opening browser window when you start - but we're not doing a browser app!

So edit package.json and replace start line with:

"start": "BROWSER=none react-scripts start",
Enter fullscreen mode Exit fullscreen mode

Add Electron

As before, no special steps needed here:

$ npm i --save-dev electron
Enter fullscreen mode Exit fullscreen mode

Add backend script index.js

We can take existing file, just point it at our dev server. When we package the app we'll need to make it aware of which environment it's in, and to point at that URL, or at the generated file, based on that.

The only difference from Svelte version is the default port number.

let { app, BrowserWindow } = require("electron")

function createWindow() {
  let win = new BrowserWindow({
    webPreferences: {
      preload: `${__dirname}/preload.js`,
    },
  })
  win.maximize()
  win.loadURL("http://localhost:3000/")
}

app.on("ready", createWindow)

app.on("window-all-closed", () => {
  app.quit()
})
Enter fullscreen mode Exit fullscreen mode

Add preload script preload.js

We don't need to do any changes, so taking it directly from the previous episode:

let child_process = require("child_process")
let { contextBridge } = require("electron")

let runCommand = (command) => {
  return child_process.execSync(command).toString().trim()
}

contextBridge.exposeInMainWorld(
  "api", { runCommand }
)
Enter fullscreen mode Exit fullscreen mode

Customize public/index.html and src/index.js

I'm only going to change the title, the ones coming from the template are good enough as they are.

src/index.css

Svelte has scoped CSS builtin, so I used that. React has packages for that as well, but since it's not builtin, I'll just use global CSS file here, pretty much the same we used before in episode 10.

Here's its contents:

body {
  background-color: #444;
  color: #fff;
  font-family: monospace;
}

.input-line {
  display: flex;
  gap: 0.5rem;
}

.input-line > * {
  flex: 1;
}

.input-line > .prompt {
  flex: 0;
}

.output {
  padding-bottom: 0.5rem;
}

.input {
  color: #ffa;
}

.output {
  color: #afa;
  white-space: pre;
}

form {
  display: flex;
}

input {
  flex: 1;
  font-family: inherit;
  background-color: inherit;
  color: inherit;
  border: none;
}
Enter fullscreen mode Exit fullscreen mode

Main component src/App.js

We just import two components and use them. For simplicity, command state will be handled by CommandInput component, I did not export it here.

The app uses window.api.runCommand which was created by the preload script before it started. As runCommand is synchronous, it can really mess our React app. We'll fix that in a later episode.

import React from "react"
import CommandInput from "./CommandInput"
import HistoryEntry from "./HistoryEntry"

export default (props) => {
  let [history, setHistory] = React.useState([])

  let onsubmit = (command) => {
    let output = window.api.runCommand(command)
    setHistory([...history, { command, output }])
  }

  return (
    <>
      <h1>React Terminal App</h1>
      { history.map(({command, output}, index) => (
        <HistoryEntry key={index} command={command} output={output} />
      ))}
      <CommandInput onsubmit={onsubmit} />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

History entry component src/HistoryEntry.js

It's completely passive, just displays two passed props:

import React from "react"

export default ({command, output}) => {
  return <>
    <div className='input-line'>
      <span className='prompt'>$</span>
      <span className='input'>{command}</span>
    </div>
    <div className='output'>{output}</div>
  </>
}
Enter fullscreen mode Exit fullscreen mode

Command input component src/CommandInput.js

It keeps command in a local state, and only callbacks when the user submits.

import React from "react"

export default ({ onsubmit }) => {
  let [command, setCommand] = React.useState("")

  let submit = (e) => {
    e.preventDefault()
    onsubmit(command)
    setCommand("")
  }

  return <div className="input-line">
    <span className="prompt">$</span>
    <form onSubmit={submit}>
      <input type="text" autoFocus value={command} onChange={(e) => setCommand(e.target.value)} />
    </form>
  </div >
}
Enter fullscreen mode Exit fullscreen mode

Result

And here's the result:

Episode 14 screenshot

This wasn't any harder than the Svelte version. Pretty much every real world React app uses long list of extra React addons like redux, immer, styled-components, and so on, and most of them work just fine with Electron, so customize to your heart's content.

In the next episode, we'll make our backend async, so a slow command won't freeze the whole frontend.

As usual, all the code for the episode is here.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on August 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related