Electron Adventures: Episode 54: Notebook state management with useImmer
Tags: javascript, react, electron, python
Published: 2021-09-17 06:07:06

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`](https://github.com/immerjs/immer) and its hooks version [`useImmer`](https://github.com/immerjs/use-immer).

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:

```css
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.

```javascript
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.

```javascript
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`:


```javascript
  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) => ...)`.

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

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

```javascript
  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:

![Episode 54 Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eq1fi5x95r196tcn0fxh.png)

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-54-notebook-state-management-with-useimmer).

### 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.