Why I Brought React-Like Hooks to Retui
I didn't set out to copy React. I set out to stop writing terminal apps the way I'd been writing them for years — a giant switch statement on key events, a pile of mutable struct fields tracking "what screen am I on" and "what's selected right now," and a Draw() function that touched half of those fields just to figure out what to paint. It worked. It just didn't scale past a few hundred lines without becoming something I was afraid to touch.
Retui is a terminal UI framework in Go. Components are functions that return an Element. You describe what the screen should look like given the current state, and the framework figures out what to paint. That part isn't controversial — plenty of frameworks work that way. The part people ask me about is the hooks.
The problem I actually had
Before hooks, my components needed somewhere to keep state between renders. A text input needs to remember its cursor position. A list needs to remember which row is selected. In a typical Go TUI, you'd put that in a struct — type TextInputModel struct { value string; cursor int } — and pass it around, or embed it in some bigger app-state struct.
That's fine for one text input. It stops being fine when you have a form with fifteen fields, three of which are conditionally rendered, and you're trying to keep your app-state struct from turning into a 200-line God object that every component reads and writes.
React solved a version of this problem with hooks — useState inside a function component, without you having to define a class or a struct to hold it. I'd used enough React to know that model removes a specific kind of busywork: you stop having to design a state shape up front. You just call UseState where you need it, and move on.
So the question wasn't "should I copy hooks." It was "can this idea survive translation into Go, where I don't have closures over component instances the way JSX does, and I don't have a virtual DOM doing reconciliation for me."
What I tried first
My first instinct was the obvious one: attach state to a component instance. Give every component a stable identity — like React does with the fiber tree — and store its hook state on that instance. This is the "correct" way to do it, and if I were building this in a language with more DOM-like tree diffing built in, I probably would have.
I got about halfway through building it before I ran into the actual hard part: identity. React can give a component a stable identity across renders because JSX gives you a tree with keys, and the reconciler walks old-tree vs new-tree and matches nodes up. Building that matching logic correctly — handling conditional children, lists, reordering — is most of what makes React's reconciler genuinely complicated software. I didn't want to build a mini React reconciler in Go just to get storage location for a UseState call. That felt like solving a problem I didn't have yet, in service of an API convenience.
What I did instead
Retui's render loop calls your component function directly, in order, every frame. There's no tree diffing keeping track of "this Button is the same Button as last render." So instead of attaching hook state to a component instance, I attach it to render order.
Concretely: there's a global hook state slice, and a cursor into it. Every time you call UseState, UseEffect, or any other hook, it grabs the next slot in that slice, based on call order, not on which component you're "in." The cursor resets to zero at the start of each render pass. This is really close to how React's own hooks work internally, actually — React also relies on call order within a single component, which is why "don't call hooks conditionally" is a rule there too. I just stretched that same constraint across the whole app instead of scoping it per-component.
The honest description: hook state in Retui is process-global, not per-component-instance. It's an array, a cursor, and a straightforward set of rules about how you're allowed to call things.
Why this actually works better than it sounds
The first time I explain this to someone, they wince a little, and I get why. "Global state" sounds like the thing you're taught to avoid. But it comes with real upside that I didn't fully appreciate until I'd been using it for a few months.
It's fast, in a boring, predictable way. There's no reconciliation pass, no tree walk, no key-based matching to figure out which node is which. Getting a hook's value is an array index. That matters more in a terminal renderer than you'd think, because unlike a browser, you're often re-rendering on every single keystroke, and you don't get to hand the expensive part off to native DOM diffing.
It's also simple to reason about, once you accept the rule. If you've written React, you already half-know the rule: don't call hooks inside conditionals or loops, call them in the same order every render. I didn't invent that constraint — I inherited it, and honestly I think it's a reasonable one to inherit, because it's already proven itself as something developers can learn and live with.
Where I paid for it
I want to be straight about the costs, because I've eaten all of them personally.
The biggest one: because hook storage is global, Retui can currently only run one app per process. There's no story yet for "run two independent component trees side by side" or "embed a Retui screen inside a larger non-Retui program." For a terminal app that owns the whole screen, this has never actually bitten me — but I know it's a real limitation the moment someone wants to do something more creative with the framework, and I don't have a great answer for them yet.
The second one is testing. Hook-touching code can't safely run in parallel test cases right now, because they're all reading and writing the same global slice. I've structured the test suite around this — nothing runs t.Parallel() where hooks are involved — but it's a constraint I have to remember, not one the compiler enforces for me. That's the kind of thing that's fine when I'm the only maintainer keeping it in my head, and less fine as more people contribute.
There's a smaller, uglier problem too: dynamic lists. If you're rendering UseState inside a loop — say, one call per row in a table — call-order-based storage breaks, because the list can grow, shrink, or reorder between renders, and "the fifth hook call" stops meaning the same thing. React handles this with key props on elements, feeding into its reconciler. I don't have a reconciler, so I couldn't reuse that mechanism. I ended up adding a second hook, UseStateKeyed, that takes an explicit string key instead of relying on position. It works, but it means there are now two ways to keep state, and a developer has to know which one to reach for. That's an API smell I made peace with rather than one I'm proud of. It's the kind of trade-off you make when you're solving a real problem under a self-imposed constraint, and I'd rather have an honest wart than pretend the constraint didn't cost me anything.
I also learned, a bit painfully, that a hook-order bug — calling UseState conditionally by accident — can silently reset a piece of state instead of loudly failing. In React that kind of mistake tends to throw. In my current implementation, it can just quietly hand you back the initial value, and you spend twenty minutes wondering why your component "forgot" what the user typed. That's on my list to fix by making it fail loudly in development builds, because a silent bug is so much worse to debug than a panic with a stack trace.
Where I think this goes next
I don't think the global-state model is the final form. If Retui grows to the point where people want multiple independent app instances in one process, or safer parallel testing, I'll need to move hook storage onto something scoped to a runtime instance instead of a package-level variable. I know roughly what that refactor looks like, and I know it's a breaking change to the internals, even if the public UseState call signature barely changes for the end user.
I haven't done it yet, for one honest reason: nobody has asked for it in a way that told me it was actually blocking them. I'd rather ship a slightly compromised design that real people are using than a theoretically cleaner one that solves a problem nobody has yet. That's a value judgment, not a law of nature, and I could be wrong about the timing.
What I took from this whole process is a smaller point about borrowing ideas across ecosystems. The part of React worth stealing wasn't the virtual DOM, or the reconciler, or JSX. It was the much simpler idea underneath all of that: let a function remember things across calls without forcing the developer to design a state container up front. That idea is portable. The machinery React built to support it, in a language and runtime shaped very differently from Go, wasn't something I needed to copy wholesale — and trying to copy it exactly would have meant solving problems Go and a terminal renderer don't actually have.
🔗 Retui: https://github.com/subhasundardass/retui
If you've ever built a framework, library, or even a complicated internal tool, I'd love to hear about a design decision you're still not completely sure about.
Those are usually the most interesting ones.
Top comments (0)