Guide
Best practices #
In Knots, the interface is a function of your data. Each frame, you describe the UI again from your data. Most bugs and most slow frames come from code that works against this model. This page tells you how to work with it.
Think in frames #
A retained-mode library and Knots work differently:
| Retained mode | Knots (immediate mode) |
|---|---|
| You create widgets one time and change them later. | You describe all widgets in each frame. |
| Widgets hold a copy of your data. You keep the copies in sync. | Components read your data through values and pointers. There is no copy. |
| Events arrive in callbacks, away from the code that made the widget. | The response comes back at the call site, in the same frame. |
| To hide a widget, you set a flag on it. | To hide a component, you do not emit it. |
Thus the rule is: keep one copy of each piece of data, and derive the UI from it in each frame.
Avoid state bugs #
Keep one source of truth #
Do not keep values that you can calculate from other values. Calculate them in the frame. A count that you calculate cannot be wrong.
// Do not do this: done_count must change at every place that changes a todo.
self.done_count += 1;
// Do this: calculate it where you show it.
var done: usize = 0;
for (self.todos.items) |todo| done += @intFromBool(todo.done);
Loops over a few thousand items are fast. Cache a derived value only after you measure that the calculation is slow.
Give components pointers to your data #
Checkbox, SliderInput, TextInput, and other
inputs take a pointer to your value and change it directly. Do not copy the value into
a temporary variable and then copy it back. Then the component and your data cannot
disagree.
// Do this: the checkbox edits the real value.
_ = try frame.interact(Checkbox{ .key = key, .checked = &todo.done, .label = todo.title });
Use stable, unique keys #
Knots finds widget state (focus, hover, scroll, caret, open menus) with the key. Most state bugs in immediate-mode code are key bugs:
-
The same key in a loop.
.src(@src())in a loop gives every item the same key. All items then share hover, focus, and scroll state. Useui.Key.str("row").indexed(item.id). - A position as the key. If you use the list index, and the user removes item 2, then item 3 gets the old state of item 2. Focus or a caret jumps to a different row. Use an ID that stays with the item.
- A key that changes each frame. A key made from a changing value, for example a label that includes a count, starts with new state in each frame. The component loses focus and animations restart.
-
The same keys in two instances of a custom component. Give the
component a
keyfield and derive the child keys withindexed.
Reset widget state on purpose #
Knots keeps widget state for 1800 frames after the last use. This is usually what you want: a hidden tab keeps its scroll position. To start again with new state, change the key:
// A new document gets a new scroll position and selection.
const editor_key = ui.Key.str("editor").indexed(document.id);
To clear an input field, clear your buffer. The buffer is the data, and the field shows it.
Act on a response immediately #
A response is valid for one frame. Use it at the call site. Do not store it and read it in a later frame.
if ((try frame.interact(Button{ .key = .src(@src()), .label = "Save" })).clicked) {
try self.save();
frame.requestRedraw();
}
Do not change a list while you iterate over it #
Record the change in the loop, and apply it after the loop. This also keeps the rest of the frame consistent with the data that the user saw.
var remove: ?usize = null;
for (self.items.items, 0..) |item, index| {
if (try removeButton(frame, item)) remove = index;
}
if (remove) |index| _ = self.items.orderedRemove(index);
Respect frame memory lifetimes #
-
Memory from
frame.arena()is released after the frame. Do not keep pointers to it in your state. - Strings that you give to components must stay valid until the frame ends. A temporary buffer on the stack of a helper function is not valid after the function returns.
- Memory for your data comes from your own allocators. The tutorial uses an arena for todo titles.
Mind the order of input #
Components handle input when you emit them, and some consume keyboard events. If you read a key for a component, read it before you emit the component:
const submit = frame.ui().focused(field.hash()) and frame.ui().input.keyPressed(.enter);
try frame.e(TextInput{ .key = field, .buf = &self.draft });
if (submit) try self.addDraft();
Keep async work away from UI state #
A function that you give to App.dispatch runs concurrently. Give it copies
of its inputs. Change your state only in the completion callback, which runs on the
main thread inside a frame.
Keep reloadable state in bound state #
In an HMR module, global variables reset on each reload. Use
frame.bindState for values that must stay. Read
Hot reloading.
Keep frames fast #
Immediate mode does work in each frame, so it is important to know which work is cheap. Knots is designed to run layout, style resolution, and packet building in each frame. The usual problems are slow work that you add to the frame, and frames that run when nothing changed.
Let the app sleep #
App runs a frame only when something changes. Do not call
requestRedraw in every frame without a reason. The app then draws
continuously, and uses CPU, GPU, and battery power.
- Call
requestRedrawonce, after you change data. - Built-in animations request their own frames and stop when they end.
- For a clock or a progress value, request frames only while it changes.
- For a change from another thread, call
app.requestFrame.
Do no slow work in the frame #
A frame function must return in a few milliseconds. Do not read files, wait on the
network, or run long calculations there. Use App.dispatch and show the
result when it arrives. Show a loading state while you wait: it is only an
if statement.
Emit only what the user can see #
-
For long lists, use
ui.control.VirtualList. It emits only the visible rows. All rows must have the same height. - Do not emit hidden content. Collapsed sections, closed tabs, and closed dialogs cost nothing when you do not emit them. Their widget state stays for later.
Allocate with the frame arena #
Use frame.arena() for strings and slices that you make each frame. The
arena keeps its capacity between frames, so after the first frames it does not ask the
system for memory. Do not use your general-purpose allocator for per-frame data.
Declare styles as constants #
A style is plain data. Declare shared styles as const values, and make
variants with &comptime base.with(.{ ... }). The merge then happens at
compile time. Inline styles such as &.{ .gap = 8 } are also free: Zig
stores them as constants.
Know the cost of text #
- Knots caches shaped text between frames. A string that does not change is cheap to show again.
- Each glyph is built once and stays on the GPU. The first frame with new text or a new font does more work.
-
Wrapped text can cause a second layout pass. Use
wrap = trueonly on text that needs it. - Load only the fonts that you use.
Use backdrop effects with care #
A backdrop blur or glass effect must read the pixels behind it. The
renderer then splits the frame into more render passes. A few backdrops are fine. Do
not put one on every list row.
Measure #
- Measure
ReleaseFastorReleaseSafebuilds, not Debug builds. -
knots.debug.DevToolsshows the frame time in the app. Read Developer tools. - The benchmark example shows how to add Tracy zones to a frame.
Checklist #
- Each piece of data has one owner. The UI reads it; it does not copy it.
- Derived values are calculated in the frame.
- Keys in loops use a stable item ID.
- Responses are used at the call site.
- Lists change after the loop, not in it.
- Nothing from the frame arena is stored in your state.
requestRedrawfollows a change, not every frame.- Slow work goes through
App.dispatch. - Long lists use
VirtualList.