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:
As usual, all the code for the episode is here.
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.
Top comments (0)