Open Menu

Cell: The Post-React Application Framework

  • Our Aspect primitive replaces useState, useEffect, signals, and stores. You simply get and set values, Cell handles dependency tracking, invalidation, and rendering.

  • There is no virtual DOM. Cell only re-renders subtrees whose inputs have changed. Cost per frame scales with what changed, not tree size.

  • Cell is built on real object-capability security. The runtime enforces constraints at the transport layer, so handlers only ever see authorized messages.

  • The built-in support for composable shaders on top of our reactive system lets app developers tap into the GPU without needing a decade of 3D experience.


Why Cell?

Modern app development is a bit of a shitfest:

  • React. Gave us nice, composable components, but chose to model them as stateless functions that fully re-execute on every render. This makes hooks a structurally unfixable abstraction:

    • useState — to bolt on state.

    • useMemo / useCallback — because the runtime can’t track what changed.

    • useEffect — to sync with anything outside the render snapshot, and the single biggest source of bugs.

    • useRef — to escape it.

    • useContext — because isolated re-renders have no natural way to share state without prop drilling.

    • useReducer — because useState can’t express multi-field transitions.

    Not to mention all the other half-baked solutions they keep introducing like: useTransition, useDeferredValue, useSyncExternalStore, useActionState, useOptimistic, etc.

    But even with all this, every medium-sized app still needs some kind of third-party state management on top. And that space remains a religious war with no winner.

  • SolidJS. Signals handle state much better, but be prepared for hard-to-debug bugs if you access signals outside of reactive scopes, forget to use getters, or just destructure props.

  • Other web frameworks are no better: Angular loves its boilerplate, Vue has a .value fetish, and even Svelte can’t escape the fundamental constraints of the web platform.

  • React Native. All the downsides of React, plus a fragile tower of npm dependencies that breaks on every upgrade. Recent changes help performance but don’t fix the programming model.

  • SwiftUI. Every year brings another hacky solution: @State, @Binding, @ObservedObject, @EnvironmentObject, @Published, @StateObject, @Observable, @Bindable, etc.

    Three reboots in less than 5 years. Unfortunately, when you’re trying to layer a declarative UI on top of an imperative object lifecycle, you can only patch it so far.

  • Jetpack Compose. Recomposition is unpredictable. Ordering matters. State hoisting brings back React prop drilling nightmares. And good luck getting away from Material Design.

  • Flutter. Most devs don’t want to learn Dart. Everything is deeply nested. And, despite having Google’s resources behind them, they still can’t get basic text input right.

  • GTK. Built on macros that make defining widgets feel like writing a compiler. Apps look alien on anything that isn’t Linux. Just getting native menu bars working is a lot of effort.

  • Qt. A macro system that manages to break even C++, with QML bolted on for laughs. Not to mention the expensive licensing model and the look-and-feel from 20 years ago.

  • Windows. Constantly changing frameworks. You get to choose between stable-but-outdated or modern-but-half-baked: Win32, WinForms, WPF, UWP, WinUI 2, WinUI 3. What next?

Cell doesn’t try to fix any of these. It starts over:

  1. Unlike the misleadingly-named React, Cell is an actual reactive framework. Changes automatically propagate to dependents. State management is baked in, not a secondary concern.

    Cell’s reactivity has no access-pattern traps. You call ctx.get and ctx.set. That’s it. The runtime tracks dependencies automatically. There’s no silent reactivity loss.

  2. Cell isn’t just a view layer that you wire into an ecosystem. Everything from state management to persistence to forms to routing to undo is baked in, and built from the same primitive.

  3. Cell renders directly to the GPU. There’s no virtual DOM or tree diffing. Our support for composable shaders lets developers tap into GPU effects without needing to be a graphics expert.

  4. Written in Zig. You get native performance without GC or hidden allocations. We catch errors at compile time via comptime. All without a borrow checker or C++ macro hell.

  5. Cross-platform across both desktop and mobile, without targeting a native look and feel. Your app looks and behaves the same everywhere.

    After all, what is “native” any more? Even the Control Panel on Windows uses 3 different styles today. This also avoids locking in a specific aesthetic like Jetpack Compose or Qt.

  6. Unlike traditional apps which can’t safely use each other’s state, Cell bakes in object-capability security. This lets us build highly composable apps with user-controlled permissions.

Easy On-Ramp

Cell starts with a straightforward hello world:

import * from "ui"

run_app {
    return text("hello world")
}
import * from "ui"

Count = aspect(int)

run_app {
    return column(gap: 8) {
        text("Count: ${$.Count}")
        button("+1").click {
            $.Count += 1
        }
    }
}

ss:

Count = aspect(int)

espra.run {
    return ui.column(gap: 8) {
        ui.text("Count: ${$.Count}")
        ui.button("+1").click {
            $.Count += 1
        }
    }
}

ss

const espra = @import("espra");
const cell = @import("cell");
const ui = @import("ui");

fn app(ctx: *cell.Context) ui.Element {
    return ui.text("hello world", .{});
}

pub fn main() void {
    espra.run(app);
}

Adding reactivity takes just a handful of extra lines:

const Count = espra.aspect(u32, .{});

fn app(ctx: *cell.Context) ui.Element {
    return ui.column(.{ .gap = 8 }, .{
        ui.textf("Count: {d}", .{ctx.get(Count)}, .{}),
        ui.button("+1").click(increment, .{}),
    });
}

fn increment(ctx: *cell.Context) void {
    ctx.set(Count, ctx.get(Count) + 1);
}

And a todo app isn’t much more code:

const Todo = struct {
    text: []const u8,
    done: bool,
};

const Input = espra.aspect([]const u8, .{});
const Todos = espra.aspect(cell.List(Todo), .{});

fn app(ctx: *cell.Context) ui.Element {
    return ui.column(.{ .gap = 12, .padding = 16 }, .{
        ui.row(.{ .gap = 8 }, .{
            ui.text_input(ctx, Input, .{ .placeholder = "What needs doing?" }),
            ui.button("Add", .{}).click(add_todo, .{}),
        }),
        ui.map(ctx.get(Todos).items(), todo_item),
    });
}

fn add_todo(ctx: *cell.Context) void {
    var todos = ctx.get_mut(Todos);
    todos.append(.{ .text = ctx.get(Input), .done = false });
    ctx.set(Input, "");
}

fn remove(ctx: *cell.Context, index: usize) void {
    ctx.get_mut(Todos).remove(index);
}

fn todo_item(ctx: *cell.Context, index: usize, todo: Todo) ui.Element {
    return ui.row(.{ .gap = 8 }, .{
        ui.checkbox(todo.done, .{}).toggle(toggle, .{ index }),
        ui.text(todo.text, .{ .strikethrough = todo.done }),
        ui.button("×", .{}).click(remove, .{ index }),
    });
}

fn toggle(ctx: *cell.Context, index: usize) void {
    var todo = ctx.get_mut(Todos).at(index);
    todo.done = !todo.done;
}

With Nyro, this becomes even cleaner:

Todo = struct {
    text  string
    done  bool
}

Input = aspect(string)
Todos = aspect([]Todo)

func app() Element {
    return column(gap: 12, padding: 16) {
        row(gap: 8) {
            text_input(Input, placeholder: "What needs doing?")
            button("Add").click {
                $.Todos.append({text: $.Input})
                $.Input = ""
            }
        }
        $.Todos.map(todo_item)
    }
}

func todo_item(index int, todo Todo) Element {
    return row(gap: 8) {
        checkbox(todo.done).toggle {
            todo.done = !todo.done
        }
        text(todo.text, strikethrough: todo.done)
        button("×").click {
            $.Todos.remove(index)
        }
    }
}

Compare the React equivalent:

// useState declarations needed in every stateful component:
const [input, setInput] = useState<string>("")
const [todos, setTodos] = useState<Todo[]>([])

<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={() => {
  setTodos([...todos, {text: input, done: false}])
  setInput("")
}}>Add</button>

ss

// useState declarations needed in every stateful component:
const [input, setInput] = useState<string>("")
const [todos, setTodos] = useState<Todo[]>([])

<input value={input}
       onChange={e => setInput(e.target.value)} />
<button onClick={() => {
  setTodos([...todos, {text: input, done: false}])
  setInput("")
}}>Add</button>

In Nyro:

// Aspects are only defined once for the whole app:
Input = aspect(string)
Todos = aspect([]Todo)

text_input(Input)
button("Add").click {
    $.Todos.append({text: $.Input})
    $.Input = ""
}

Far less ceremony. You read the state. You write the state. That’s it.

Accessibility

Cell builds a parallel accessibility tree from .aria declarations. These are bridged to platform-native APIs:

API Platform
Accessibility Framework Android
UIAccessibility iOS
AT-SPI2 Linux
NSAccessibility macOS
UI Automation Windows

All ui elements support .aria declarations:

ui.button("Save")
  .aria(.{ .label = "Save document" });

ui.column(.{ .gap = 8 }, .{
    ...
  })
  .aria(.{ .role = .navigation, .label = "Main menu" });

ui.text_input(Email, .{})
  .aria(.{ .description = "We'll never share your email" });

ui.image(chart_img, .{})
  .aria(.{ .label = "Revenue chart showing 20% growth" });

Dynamic content can control how it’s announced by assistive technologies:

ui.text(status_message, .{})
  .aria(.{ .announce = .queued });

ui.text(error_count, .{})
  .aria(.{ .announce = .immediate });

Built-in elements have sensible defaults that minimize unnecessary typing, e.g.

  • ui.button has role set to .button
  • ui.text_input has role set to .textfield
  • Form fields are automatically associated with their labels

Application developers can choose to override these when needed. Where a platform doesn’t support an ARIA feature, Cell maps it to the closest equivalent and degrades gracefully.

The accessibility tree is updated incrementally, with the same dirty tracking as the rest of the view.

Timers

Cell provides several functions for time-dependent behaviour. The ctx.after method can be used to register a handler that will get called after the given number of milliseconds, e.g.

ctx.after(200, flush_pending, .{});

To repeatedly call a handler every N milliseconds, ctx.every can be used, e.g.

ctx.every(1000, poll_status, .{ health_endpoint });

These handlers will be called in the very first step of the tick loop alongside IO completions. Each handler is invoked with the matching ctx and any arguments specified at registration:

fn poll_status(ctx: *cell.Context, url: URL) void {
    ...
}

Both ctx.after and ctx.every return a cancel function, e.g.

var cancel_poll = ctx.every(1000, poll_status, .{ health_endpoint });

// Sometime later ...
cancel_poll();

Because timers run inside the same tick model, delayed and repeated work stays predictable and race-resistant whilst still supporting debouncing, throttling, polling, scheduling animations, etc.

Layers

Anyone who’s had to deal with z-index in CSS knows how painful it can be. While the more recent Popover and Dialog APIs help to reduce the pain, we believe we can do better.

Cell defines a fixed stacking order for all UI:

const Layer = enum(u3) {
    bottom,       // backgrounds
    default,      // normal content
    overlay,      // sidebars, floating panels
    dropdown,     // select menus, autocomplete
    popover,      // rich hover cards
    modal,        // dialogs
    notification, // toasts
    top,          // tooltips, drag ghosts
};

Render content to a specific layer with ui.layer, e.g.

ui.layer(.modal,
    ui.column(.{ .align = .center }, .{
        ui.text("Are you sure?", .{}),
        ui.button("Yes", .{}).click(confirm, .{}),
        ui.button("No", .{}).click(cancel, .{}),
    }),
);

The ui.layer call returns a ui.none() in the local tree so it doesn’t affect the layout. Multiple ui.layer calls to the same layer will render in the tree order they appear in within the .default layer.

By predefining a fixed set of layers, we make life simple for app developers. The framework simply renders all eight layers in enum order.

Tick Loop

Cell runs the following loop on every tick:

  1. Drain IO completion queue + timer queue
  2. Merge remote updates → mark dirty cells
  3. Poll input → convert to messages
  4. Run enrichers (hit testing, modifiers; see previous tick’s state)
  5. Phase 1: Input handlers → cell mutations
  6. Phase 2: Derived + network handlers → cell mutations
  7. Recompute dirty set after handler mutations
  8. Run intra-context rules (topological order, skip clean inputs)
  9. Run cross-context rules (batched to ~10ms)
  10. Submit GPU work (2D UI + 3D scene render passes)
  11. Run behaviors
  12. Reconcile widgets (reactive invalidation, not tree diff)
  13. Layout pass
  14. Rebuild GPU draw buffer (incremental patches)
  15. Clear dirty flags, changelogs
  16. Free tick-scoped arena

Quickstart

We provide an espra.run utility function that:

  • Creates a local app instance and a root context.

  • Calls the entry function passed into espra.run.

  • If the function returns a ui.Element, it opens a window, renders the view, and runs the tick loop.

  • If the function returns void, it runs in headless mode, i.e. just the tick loop without rendering.