Electron Adventures: Episode 54: Notebook state management with useImmer

For most of this series we used Svelte, which is extremely flexible at managing complex state. You can modify whatever you want wherever you want, and at most you'll just need to ping the component with foo = foo to let it know that foo changed in some unusual way. Usually you don't even need that.

React is a lot more strict, and for what we need we cannot leave the state in individual components, we need to pull it up to the App component. Making modifications to deeply nested state is a lot of nasty code, fortunately React world has a solution - immer and its hooks version useImmer.

So first we npm install use-immer, and then we can start!

src/index.css

We'll be adding some buttons so we need to add just a small entry for buttons. Here's the whole file:

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

.command {
  width: 80em;
  margin-bottom: 1em;
}

.command textarea {
  min-height: 5em;
  width: 100%;
  background-color: #666;
  color: #fff;
  font: inherit;
  border: none;
  padding: 4px;
  margin: 0;
}

.command .output {
  width: 100%;
  min-height: 5em;
  background-color: #666;
  padding: 4px;
}

button {
  background-color: #666;
  color: #fff;
}

src/Command.js

This component has handles a piece of code, its associated output, and a few buttons related to it.

The interesting thing is updateEntry code, which gets part of an useImmer-managed draft, and can do deep modifications to it.

I was wondering if this component should also manage run, deleteThis, and addNew - and with useImmer it's actually quite fine. I ended up not doing this, as App also needs Run All button, and having Run in the App, but Delete and Add New managed in the Command component felt weird.

import React from "react"

export default ({input, output, updateEntry, run, deleteThis, addNew}) => {
  let handleChange = e => {
    updateEntry(entry => entry.input = e.target.value)
  }

  let handleKey = (e) => {
    if (e.key === "Enter" && e.metaKey) {
      run()
    }
  }

  return (
    <div className="command">
      <textarea
        className="input"
        onChange={handleChange} value={input}
        onKeyDown={handleKey}
      />
      <div className="output">{output}</div>
      <div>
        <button onClick={run}>Run</button>
        <button onClick={deleteThis}>Delete</button>
        <button onClick={addNew}>Add New</button>
      </div>
    </div>
  )
}

src/App.js

The App component is pretty big, so let's cover it piece by piece.

The template is easy enough. The most unobvious way is that we do run={run(index)} instead of more usual run={(event) => run(index, event)}. I think this is clearer, as template is already very busy, and too many => there make it very difficult to read.

import React from "react"
import { useImmer } from "use-immer"
import CommandBox from "./CommandBox.js"

export default (props) => {
  ...

  return (
    <>
      <h1>Notebook App</h1>
      {notebook.map(({input,output}, index) => (
        <CommandBox
          key={index}
          input={input}
          output={output}
          updateEntry={updateEntry(index)}
          run={run(index)}
          deleteThis={deleteThis(index)}
          addNew={addNew(index)}
        />
       ))}
      <div>
        <button onClick={runAll}>Run All</button>
      </div>
    </>
  )
}

But first we need to create the state. I just added some random Python snippets. useImmer has very similar API to useState:

let [notebook, updateNotebook] = useImmer([
    { input: "print('Hello')", output: "" },
    { input: "print('World')", output: "" },
    { input: "print(f'2+2={2+2}')", output: "" },
  ])

Now here's the fun one - updateEntry. It's a curried function, which we take full advantage of by doing updateEntry={updateEntry(index)} in the template.

The CommandBox component only modifies the first argument of its callback. I also sent it draft and index because I thought addNew and deleteThis are going to be managed there, then I ended up not doing that, but I think it's fine to leave the API a bit more flexible. It's similar to how a lot of JavaScript callbacks pass extra index argument that's usually ignored. For example .map(element => ...) is really .map((element, index, array) => ...).

let updateEntry = (index) => (cb) => {
    updateNotebook(draft => {
      cb(draft[index], draft, index)
    })
  }

All the buttons follow similar curried pattern, and have fairly simple handlers:

let run = (index) => async () => {
    let input = notebook[index].input
    let output = await window.api.runScript("python3", input)
    updateNotebook(draft => { draft[index].output = output })
  }

  let addNew = (index) => () => {
    updateNotebook(draft => {
      draft.splice(index + 1, 0, { input: "", output: "" })
    })
  }

  let deleteThis = (index) => () => {
    updateNotebook(draft => {
      draft.splice(index, 1)
      if (draft.length === 0) {
        draft.push({ input: "", output: "" })
      }
    })
  }

  let runAll = async () => {
    for (let index = 0; index < notebook.length; index++) {
      await run(index)()
    }
  }

Result

Here's the results:

Limitations

We made the frontend good enough for a simple notebook, but every code box is still running as an unconnected script.

There's also a bit of a race condition that if code is taking a while to finish, and user deletes or adds boxes while the code is running, the output is going to go to the wrong place, but let's not worry too much about it for now.

The next step is using simple HTTP backend to run various code bits we send to it, in shared context.

28