How Shirei retains component identity and state
The API is immediate mode, but components have consistent identity and retain their state across frames
2026.08.19
Shirei is a practical GUI framework for writing GUI apps as native Go programs — not web pages.
I used to very proactively describe it as "immediate mode", but recently I am starting to use the term "declarative" instead, because it deviates from several "de facto standard" conventions about what "immediate mode" means (or implies).
For one thing, it's not a C++ library, and it's not meant to be used with game engines.
In fact, we don't even use GPU for rendering — at least at the time of this writing.
More importantly though, the API we expose is not about "drawing" things to the screen "immediately".
Rather, what you do "immediately" is create a container tree that describes what the UI should look like at the end of this rendering cycle.
The containers do not even have their sizes and positions "resolved" when you create them; that gets resolved later by the core engine.
In a way, it's similar to the React model, at least on a superficial level.
I still think it qualifies as "immediate mode", because the way you use the API, your code does not keep widget objects around to pass to the API.
If you have a complex data structure that you want to reflect in the UI, you do not need to create and maintain a mirror widget structure. You just write procedural code to traverse or iterate your data structure.
The UI is not created by gluing together a bunch of widget objects.
The UI is built instead by running a view function that builds a container tree, and then having the system run the layout algorithm on that tree and turning it to a graphical interface to display on the screen.
Procedurally building a container tree
The biggest difference between Shirei view functions and React style functions, is the function signature.
A react style function takes one argument: a "props", and returns a JSX Element, which is essentially a description of what a "subtree" of the dom is supposed to look like.
type MyComponentProps = {
...
}
function MyComponent(props: MyComponentProps): JSX.Element {
return <div>
...
</div>
}
Shirei, on the other hand, does not pass around container trees as objects. Instead, you build the container tree procedurally: opening a new container, setting attributes on it, and creating child containers. The child containers are also built the same way: procedurally.
So, if you have an html element tree like this:
<div style="background: hsl(0, 10%, 90%); padding: 10px">
<div style="width: 100px; height: 80px; background: hsl(0, 50%, 50%)">
</div>
</div>
We build it procedurally this way (conceptually):
open container
set attribute: background = (0, 10, 90, 1)
set attribute: padding = (10, 10, 10, 10)
open container
set attribute: min width = 100
set attribute: max width = 100
set attribute: min height = 80
set attribute: max height = 80
set attribute: background = (0, 50, 50, 1)
close container
close container
This requires there to be an invisible "builder" object that is keeping track of the tree structure as it is being built.
This is another aspect of being "immediate": you're manipulating a data structure, you are issuing commands to a system.
Building the tree procedurally allows us to freely change the steps being performed based on current conditions. For example, if we are being hovered, we can change the background.
open container
...
open container
...
set attribute: background = (0, 50, 50, 1)
if hovered() {
set attribute: background = (0, 50, 70, 1)
}
close container
close container
In Shirei, the actual code looks like this:
Container(Attrs(Background(0, 10, 90, 1), Pad(10)), func() {
Container(Attrs(FixSize(100, 80), Background(0, 50, 50, 1)), func() {
if IsHovered() {
ModAttrs(Background(0, 50, 70, 1))
}
})
})
The function Container takes as a parameter the initial attribute set and the "builder"
function. Implicitly, the open container happens at the start of the builder function, and
the close container happens at the end of it.
Attrs is a function that allows specifying the attributes ergonomically using a set of
helper functions. It's only there for "aesthetic" reasons, to avoid the code looking like this:
Container(Attributes{
Background: Vec4{0, 10, 90, 1},
Padding: Vec4{10, 10, 10, 10},
}, ...)
FixSize is a helper function that sets both min size and max size at the same time. Here
is the actual, full implementation of it:
func FixSize(w, h float32) AttrsFn {
return func(a *AttrSet) {
a.MaxSize = Vec2{w, h}
a.MinSize = Vec2{w, h}
}
}
Most of these helpers don't do much of "computational work" at runtime; they are there to serve aesthetic purposes.
Opening a container and setting the attributes are usually the same operation, but changing the attributes has to happen inside the builder function, because we cannot check if the current container is hovered unless we first "open" it.
Container Types
Unlike the DOM, there is only one container type: Container.
We do have a function to draw a button. It's basically Button(icon, label).
But Button is not a container type. It's just a function that builds out a container
tree that, when rendered to the screen, looks like a button. The function also handles
input and changes the appearance of the button based on the user interaction: highlighting
it when hovered, pressing it when pressed.
This is not a "quirk" in the framework; it's a deliberate architecture design decision.
By treating all containers as the same, we greatly simplify the code that performs container layout. We just walk through the container tree and run the same logic on every level. We never had to check the container type to apply special "rules" to it. All the "rules" are present in the attribute set.
Whether we are a button or a text input or a text label or a scrollbar - is a concern of the UI builder code.
Once the container tree is built, Shirei's layout engine takes over to size and position all the elements, route events, retain identities, etc.
Now, the astute reader might have noticed the function IsHovered() and wondering what it
means and how it works.
If the ui building code only declares the shape the container tree, not the sizing or positioning, how does it know whether the current container is being hovered?
The answer is that it's based on where this container was positioned in the previous frame.
But this assumes we know where this container was on the previous frame, which assumes that we know the "identity" of the current container, relative to the containers created on the previous frame.
But how does that work? Doesn't immediate mode mean we build the UI, render it to the screen, then throw away all the data?
Well, yes and no. We do throw away the container tree, but we do keep around a parallel tree, which we use to resolve container identities and remember their states.
Container identity
A Container has a continuous identity across frames based on its position in the tree.
How can this work?
Let's step out of Shirei for a bit and think more abstractly. Imagine a node tree where each node has a "key" that identifies its type.
Imagine two node trees, one produced at frame N, and the other produced at frame N+1
Tree at frame N:
- Root
- A
- B
- C
Tree at frame N+1:
- Root
- A
- B
- D
- C
We can easily see that a container like "Root -> A" is the same: the node has the same name, and its parent is also the same.
"Root -> C" is also easily identifiable as the same container. Even though its absolute position relative to its parent changes (at frame N it's the third child, but at frame N+1 it's the fourth child).
How do we know it's the same? Because we use the node type as part of positioning; in both frames, it's the first C node relative to its parent.
I said earlier that containers do not have "types", but we can identify the container by its location in the codebase. We refer to this in an abstract way as an "implicit type".
Now, if we render containers in a loop, then all containers will have the same "implicit" type, which might create problems if the ordering of the loop can change across frames.
In cases like this, we can use an explicit key per container, which is the same solution React resorts to:
for idx := range list {
item := &list[idx]
ContainerWithKey(item.id, Attrs(...), func() {
....
})
}
So, let's consider the following snippet
attrs := Attrs(Expand, Pad(10), Corners(6), Background(145, 25, 92, 1))
if options.ShowEmail {
Container(attrs, func() {
Label(contact.Email)
})
}
if options.ShowPhone {
Container(attrs, func() {
Label(contact.Phone)
})
}
We have two Container calls, guarded by an if statement. If the first conditional
changed from true to false, the position of the phone container, relative to its parent,
is going to change, but, that container has a distinct implicit key from the email
container, so the implicit id is easy to resolve correctly.
Retained state
We use the retained id in order to retain the layout information: the size and position of this container, so we can query it at later frames.
More than that, we allow retaining arbitrary "state" related to the current component.
This allows components to embody complex behavior without the caller having to retain any state themselves.
The "API" presented appears "immediate", but behind the scenes there is retained state.
Here's a demo of a "special label" component. It features "two" retained state variables:
- Has this component ever been hovered?
- What is the requested text size?
It "hides" the label until the component has been hovered at least once. It shows small text control buttons that increase or decrease the text size.
func SpecialLabel(text string) {
Container(Attrs(Corners(4), Gap(10), Pad(20), BorderColor(0, 50, 50, 1), BorderWidth(1)), func() {
type MyState struct {
hoveredOnce bool
sizeInc int
}
// retained component state
s := Use[MyState]("my-state")
// ephemeral frame state
var hoveredNow = false
if IsHovered() {
hoveredNow = true
s.hoveredOnce = true
}
textColor := Vec4{0, 0, 0, 0}
if s.hoveredOnce {
textColor[3] = 1
}
if hoveredNow {
Container(Attrs(Row, Float(2, 2), Gap(4)), func() {
if CtrlButton(SymPlus, "", true) {
s.sizeInc += 5
}
if CtrlButton(SymMinus, "", true) {
s.sizeInc -= 5
}
})
}
Label(text, FontSize(20+float32(s.sizeInc)), TextColorVec(textColor))
})
}
func root() {
ModAttrs(Spacing(20))
SpecialLabel("Hello")
SpecialLabel("World")
}
Multi-pass layout rendering
Getting the position data from the retained state has a caveat: on the first frame, there is no size or position data, as the element has not had its position and size determined yet. To work around this, we can run the UI building code several times even within the same frame cycle. This means, the resolved size and position of this container will be available on the second pass.
A second layout pass can also be triggered when an element moves.
See, the rendering cycle goes roughly through the following stages:
- Collect input data from platform layer
- Run user provided UI builder code to produce the container tree
- Resolve all sizes and positions of containers in the tree
- Produce rendering primitives (Surfaces)
- Render Surface list onto platform provided window surface buffer
Steps 2 and 3 are what can run "multiple times".
The catch is that we limit how many times we do this. Currently we only do it once, meaning we can run a second pass, but we will not run a third pass.
So, even though the UI builder code will be dealing with data from the previous frame, in practice, most of the time, there will be no noticeable glitch.
Hovering mechanics
Since I used IsHovered() as the hook for this article, I feel the need to clarify one
important aspect of hovering detection logic: it's not just about whether the mouse is over
the container's rectangle on the screen; it also requires understanding that containers can
overlap, and we need to know which containers are in front of other containers.
So at the end of each frame, we record the list of hoverable containers in paint order: if two or more containers overlap, the latest one to be painted is the one most in front.
Then, at the start of the next frame, we walk that list backwards, and the first rect to contain the current mouse position "wins" as the container that is being directly hovered. All of its parent containers are then also considered as "hovered", though indirectly.
During UI building, when we call IsHovered() we do not yet have the full picture of what
the situation regarding container ordering will look like. We need to wait for the UI
building to finish for this frame, and for sizing, positioning, and ordering to all be
resolved.
This is why we must use the data from the previous frame, and why this mechanism cannot workunless we do retain enough state so that we can identify containers across frames.

GitHub