A few months ago I wanted to build a small command-line tool. Nothing fancy — just a form, a list, maybe a modal dialog. The kind of thing you'd knock out in an afternoon with a modern app framework.
Then I looked at what "building a UI" actually means in a terminal, in Go.
It means you get a grid of characters. You decide what character goes in row 12, column 40. You track cursor position by hand. If something changes — say, a counter goes from 4 to 5 — you are responsible for figuring out that only that one character needs to be redrawn, and you write the code to redraw it, or you just repaint the whole screen and accept the flicker.
I sat there thinking about how modern UI toolkits — component trees, widgets, declarative layout — solved this exact problem years ago on the web and on mobile. Nobody manually paints pixel-by-pixel anymore. You just describe what the UI should look like right now, and something underneath figures out the minimum work to make that true.
So I asked a question that turned into a much bigger rabbit hole than I expected: could I build that "something underneath" myself, with nothing but 80 columns and 24 rows of monospace text to work with?
That question is what this article is actually about. Not "look at my project" — but the three ideas that quietly hold together every declarative UI system you've ever used, which I only truly understood once I was forced to build cheap, ugly, terminal-shaped versions of them myself. If you've ever used a state hook or a widget tree without thinking about what it's doing underneath, I think you'll enjoy this.
(If you want to see where all of this landed, it's an open-source terminal UI framework for Go called retui — link's at the bottom too, no pressure, read on first.)
Puzzle #1: Where does component state actually live?
Here's a question that sounds trivial until you try to implement it: when a component asks for a piece of state and gets back a value plus a setter, where does that value actually live between renders?
It's not a local variable — those reset every time the component function runs, and it does run again on every render. It's not a class field — you don't need a class for this pattern. So where does it live, and how does the framework know to hand back the fifth update instead of the first?
The answer, once you build it yourself, is almost anticlimactic: the state lives outside the component entirely, in a plain list, indexed by call order.
Here's roughly what that looks like when you strip away all the optimizations:
govar stateSlots []interface{}
var stateCursor int
func UseState(initial interface{}) (interface{}, func(interface{})) {
idx := stateCursor
stateCursor++
if idx >= len(stateSlots) {
stateSlots = append(stateSlots, initial)
}
setState := func(newVal interface{}) {
stateSlots[idx] = newVal
requestRerender()
}
return stateSlots[idx], setState
}
Before every render, stateCursor resets to zero. Your component function runs top to bottom, and every state call claims the next slot in order — the first call always gets slot 0, the second call always gets slot 1, forever, on every single render.
This single fact is why "don't call state hooks conditionally" is a rule in basically every framework that uses this pattern. It's not an arbitrary style guideline — it's a direct consequence of how the storage works. If you wrap a state call in an if, you shift every call after it into the wrong slot on renders where the condition is false, and your component starts reading and writing the wrong state entirely. Once you've built the slot array yourself, that rule stops feeling like a rule and starts feeling like simple arithmetic.
*Puzzle #2: How do you redraw only what changed, on a grid of text?
*
Slot-based state solves storage. It doesn't solve the much scarier problem: once state changes, how do you update the screen without repainting everything?
Repainting everything is easy, and it's also what full-screen terminal apps did for decades — clear the screen, redraw every line. You'll notice the flicker immediately if you've ever used an old ncurses app over a slow connection.
The fix is the same one modern UI frameworks landed on, on the web and on mobile: diffing. You render your UI as a tree of lightweight description objects (an "element tree," not the real thing on screen), compare it to the tree from last time, and only touch the parts that differ.
A minimal version of that idea looks like this:
gofunc diff(old, new Element, x, y int) {
if old == nil && new != nil {
paint(new, x, y)
return
}
if old != nil && new == nil {
erase(old, x, y)
return
}
if old.Text != new.Text || old.Style != new.Style {
paint(new, x, y)
}
for i := range new.Children {
diff(childAt(old, i), new.Children[i], childX(i), childY(i))
}
}
Nothing here is clever. It's a recursive tree walk, comparing corresponding nodes and only issuing a paint instruction where something actually differs. What surprised me wasn't the diffing itself — it's that once you have it, an enormous amount of complexity elsewhere in the framework simply disappears. Your component doesn't need to know if it's the first render or the fiftieth. It doesn't need to track "did my text change." It just describes what it wants, every time, and the diffing layer absorbs all the bookkeeping. That's the actual gift declarative UI gave developers, and it turns out to be just as valuable when the "screen" is a grid of monospace characters instead of pixels.
Puzzle #3: The bug that taught me the most — events don't know when to stop
Here's a problem that has nothing to do with rendering and everything to do with a mental model I hadn't questioned before: when a key is pressed, who gets it, and who decides when it's "handled"?
Say you have a modal dialog open — a settings panel, a confirmation box, whatever — sitting on top of your main screen. You press Escape. Obviously the modal should close. But naively, the same Escape keypress often also reaches whatever's listening underneath, and now your entire app quits too, because your main screen also happens to treat Escape as "exit."
Every layered UI system needs an answer for this — some way for an inner layer to say "I've dealt with this, don't let it go any further." Most of us have used that mechanism a hundred times without thinking hard about what it's actually preventing. I hadn't, either — not until I had to build the equivalent from scratch, with no framework underneath me to lean on.
The fix, once you see it, is almost embarrassingly simple: every layer that can handle a key needs to return a plain bool — "did I consume this?" — and every layer above it needs to actually check that value before deciding to do anything itself.
gofunc (w *Window) HandleKey(key Key) bool {
if w.onKeyPress != nil {
return w.onKeyPress(key) // true = stop here
}
return false
}
The subtle trap is that it's incredibly easy to write this correctly at one layer and then forget to check the return value one layer up — the compiler won't catch it, because ignoring a bool is perfectly legal Go. I did exactly that. My modal closed, and then, one frame later, so did the app underneath it — because the outer event loop was still calling the dispatch function and just throwing away what it returned. The fix was one line. Finding it took an embarrassingly long time, because the bug only shows up on the specific key that both layers care about.
If there's one thing I'd want a reader to take away from this whole piece, it's that: "stop this event from going any further" isn't a web-specific or mobile-specific idea. It's the general answer to "multiple things want to react to the same event, but only one of them should win," and that problem shows up anywhere you have layered UI — web, terminal, mobile, doesn't matter.
Why I think this is worth trying yourself
None of this is novel. Every modern declarative UI system — on the web, on mobile, wherever — converges on similar answers, because the underlying problem is the same regardless of what's rendering the pixels (or characters). But there's a real difference between reading that state hooks are "just an array indexed by call order" and being the one who has to make that true, bug by bug, at 11pm, wondering why your fifth state call is returning your third component's data.
If you've ever wanted a weekend project that will genuinely deepen how you think about a framework you already use every day, I'd recommend picking the smallest possible surface — a todo list, a counter, anything — and building the rendering layer underneath it yourself, with no framework at all. You will hit the exact three puzzles above, in roughly that order, and you'll come out the other side reading your favorite framework's source with completely different eyes.
Where this actually landed
I kept building past the weekend-project point, mostly because Go terminal apps turned out to be genuinely fun to work on. That turned into retui — a small, component-based framework for building terminal UIs in Go, with state hooks, a flexbox-ish layout system, and the diffing/event-propagation machinery described above holding it together underneath.
🔗 Repo: github.com/subhasundardass/retui
It's still young, the API still has rough edges, and I'd genuinely welcome anyone poking holes in it, filing issues, or just telling me which part of this post made you go "wait, that's not how I'd have done it" — that's usually where the interesting conversations start.
What's the last piece of "invisible framework magic" you got curious enough to actually go implement yourself? I'd love to hear about it in the comments.
Top comments (0)