docs/guide/project-structure
Get started · Organizing code

Project structure

Put the build entry, feature UI, styles and assets where they can grow, and know who owns each piece of memory.

6 min read

Suggested layout

zenit doesn’t impose a directory layout. Keep the build files stable and put each feature’s tree, state and styles side by side, so changing one feature doesn’t mean hopping around the repo.

myapp/
myapp/
├── build.zig
├── build.zig.zon
└── src/
    ├── main.zig
    ├── app_state.zig
    ├── assets.zig
    └── features/
        └── dashboard/
            ├── view.zig
            ├── state.zig
            └── styles.zig

File responsibilities

FileOwnsAvoid
main.zigAllocator, App init, root mountPiling up page-level UI
view.zigThe node tree and event wiringRaw colors, heavy data transforms
state.zigBusiness state and its methodsLong-lived pointers to nodes that may be removed or rebuilt
styles.zigToken-driven named style functions, fn (*const ui.ThemeTokens) ui.BoxStyleDoing IO or mutating state

Style functions are pure; builders like ui.boxStyled / ui.vstackStyled evaluate them at mount, and replay them with the new tokens when the theme changes. Inline literal styles don’t get that.

styles.zig + view.zig
// features/dashboard/styles.zig
const ui = @import("ui");

pub fn card(t: *const ui.ThemeTokens) ui.BoxStyle {
    return .{
        .padding = ui.Padding.all(t.space._6),
        .gap = t.space._4,
        .background = t.color.bg_secondary,
        .corner_radius = t.radius.xl,
    };
}

// features/dashboard/view.zig
const S = @import("styles.zig");
const panel = try ui.vstackStyled(cx, S.card, .{});

Import boundary

The public surface has two tiers: common types and builders live at ui.X, advanced features are grouped under ui.<group>.X (ui.widgets, ui.fx, ui.events, ui.theme, …). Everything outside those two tiers is internal.

zig
const ui = @import("ui");

// Tier 1: what most UI code needs
const Node = ui.Node;
const Signal = ui.Signal;

// Tier 2: grouped by concern
const Router = ui.fx.Router;
const Button = ui.widgets.Button;
const Event = ui.events.Event;

The build system enforces this: zenit.attach gives the app only the ui and zenit_app modules, so internal modules such as render and gpu can’t be imported at all (see the diagram on the Overview).

Ownership and lifetimes

Two owners, two lifetimes. Cx is window-level: it owns the node tree and every struct allocated by cx.bindState until Cx.deinit. Scope owns reactive resources (Signals, Memos, Effects, onCleanup callbacks and registered resources) and frees them on dispose(); child scopes go with their parent.

OWNERSHIP
Disposing a page Scope does not free the structs you bindState-ed on that page. They live until the window closes. Cx.deinit runs: dispose the root Scope, then free nodes, then free state.

Bound state: declare a pub deinit

If the struct declares a pub deinit(*T), the framework calls it automatically before freeing the struct at Cx.deinit. If cleanup needs an allocator, store one as a field.

state.zig
const Item = struct { title: []const u8 };

const Model = struct {
    allocator: std.mem.Allocator,
    items: std.ArrayList(Item) = .empty,

    // Must be pub: Cx.deinit detects it and calls it before freeing the struct.
    pub fn deinit(self: *Model) void {
        self.items.deinit(self.allocator);
    }
};

const model = try cx.bindState(Model, .{ .allocator = cx.allocator });
// Do NOT also call scope.onCleanup(Model, model, Model.deinit):
// that would run deinit twice.

Page-lifetime resources: onCleanup

Reach for scope.onCleanup in just two cases: the cleanup isn’t pub, or the resource must follow a Scope’s lifetime (say, a page) rather than the window’s. Allocate such objects yourself instead of through bindState.

features/dashboard/view.zig
const PageCache = struct {
    allocator: std.mem.Allocator,
    rows: std.ArrayList(Item) = .empty,

    fn release(self: *PageCache) void {
        self.rows.deinit(self.allocator);
        self.allocator.destroy(self);
    }
};

fn mountDashboard(cx: *ui.Cx, parent: *ui.Scope) !*ui.Node {
    const scope = try parent.childScope();

    // Page-lifetime resource: freed when this scope is disposed,
    // not when the window closes.
    const cache = try cx.allocator.create(PageCache);
    cache.* = .{ .allocator = cx.allocator };
    scope.onCleanup(PageCache, cache, PageCache.release) catch |err| {
        cx.allocator.destroy(cache);
        return err;
    };

    return ui.vstack(cx, .{ .gap = cx.tokens.space._4 }, .{});
}

Checklist

  • ✓

    Keep main.zig short. Allocator, App.init and runWith. Nothing else.

  • ✓

    Import only ui and zenit_app. If something you need isn’t on the two public tiers, file an issue instead of reaching into internals.

  • ✓

    Use bindState for window lifetime, Scope for page lifetime. Give the former a pub deinit, the latter an onCleanup, never both.

  • ✓

    Write styles as named functions. Put them in styles.zig and consume them with the *Styled builders so theme switches just work.

zenit · Dual-licensedFree for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.Contact the author: zongyi.xzy#gmail.com (replace # with @)zenit 4c50245 2026-10-01