---
title: "Project structure | zenit Zig UI Docs"
description: "Put the build entry, feature UI, styles and assets where they can grow, and know who owns each piece of memory."
url: https://ui.surf/zenit/docs/guide/project-structure
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/project-structure.md
alternate_es: https://ui.surf/zenit/es/docs/guide/project-structure.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/project-structure.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/project-structure.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/project-structure.md
alternate_de: https://ui.surf/zenit/de/docs/guide/project-structure.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# Project structure

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

## 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

| File | Owns | Avoid |
| --- | --- | --- |
| `main.zig` | Allocator, App init, root mount | Piling up page-level UI |
| `view.zig` | The node tree and event wiring | Raw colors, heavy data transforms |
| `state.zig` | Business state and its methods | Long-lived pointers to nodes that may be removed or rebuilt |
| `styles.zig` | Token-driven named style functions, `fn (*const ui.ThemeTokens) ui.BoxStyle` | Doing 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`

```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](https://ui.surf/zenit/docs#architecture)).

> WARNING
> 
> **The API is pre-1.0.** zenit is public and semver-versioned since 0.1.0, but before 1.0 a minor version may still break (every change is recorded in docs/MIGRATION.md). Pin an exact version or revision rather than following a branch head.

## 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`

```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.
```

> WARNING
> 
> **Don’t clean up twice.** Declaring a pub `deinit` on a `bindState` struct *and* registering `scope.onCleanup(…, T.deinit)` runs deinit twice: once at scope dispose, once at `Cx.deinit`. A non-pub `deinit` is not detected and never auto-called.

### 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`

```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.
