Docs

JSX and components

NSH apps describe their UI with elements and components, in Rust or in TSX. Rust builds elements with functions and typed builder methods (view().gap_4().child(..)); TSX writes them as JSX, which NSH’s compiler turns into direct calls of the same native renderer, with no React and no virtual DOM. Either way, a component is a function that runs once to build its part of the tree. What keeps the UI up to date afterwards is reactivity: the bindings that read signals are followed natively, and only they run again.

guide-components.tsx
guide-components.tsxLive
import { type InputProps, type JSX, mount, Scroll, signal, split, Text, View, type YNode } from "nsh";
import { Input } from "nsh/base";
import { button, buttonText, desktop, field, tint, ui, wide } from "./palette";

/** A component: a function of its props, run once. */
function Badge(props: { label: string; color?: string }) {
  return (
    <View style={{ alignSelf: "flex-start", borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: props.color ?? tint(ui().accent, 0.14) }}>
      <Text style={{ fontSize: 13, fontWeight: "600", color: ui().text }}>{props.label}</Text>
    </View>
  );
}

/** Children are whatever the caller put between the tags. */
function Card(props: { title: string; children?: JSX.Element }) {
  return (
    <View
      style={{ ...ui().card, borderRadius: 16, padding: 14, gap: 10 }}
    >
      <Text style={{ fontSize: 13, fontWeight: "600", color: ui().muted }}>{props.title}</Text>
      {props.children}
    </View>
  );
}

/** Own props are split off; the rest is spread onto the input and stays reactive. */
function Field(props: InputProps & { label: string }) {
  const [own, input] = split(props, ["label"]);
  return (
    <View style={{ gap: 4 }}>
      <Text style={{ fontSize: 13, color: ui().muted }}>{own.label}</Text>
      <Input {...field()} {...input} />
    </View>
  );
}

function App() {
  const name = signal("");
  const taps = signal(0);
  let nameInput: YNode | undefined;

  return (
    <Scroll style={{ flex: 1, backgroundColor: ui().page }}>
      <View
        style={
          wide()
            ? { padding: 20, paddingTop: 24, gap: 16, display: "grid", gridTemplateColumns: "repeat(2, 1fr)", alignItems: "start" }
            : { padding: 20, paddingTop: 24, gap: 16 }
        }
      >
        <Card title="Props are live">
          <Badge label={`${desktop ? "Clicked" : "Tapped"} ${taps()} times`} color={taps() % 2 ? tint(ui().warn, 0.16) : tint(ui().accent, 0.14)} />
          <View onPress={() => taps.update((n) => n + 1)} {...button("primary")}>
            <Text style={buttonText("primary")}>{desktop ? "Click" : "Tap"}</Text>
          </View>
        </Card>

        <Card title="split, spread and ref">
          <Field label="Your name" placeholder="Type here" value={name()} onChange={(t) => name.set(t)} ref={(node) => (nameInput = node)} />
          <Text style={{ color: ui().text }}>{name() ? `Hello, ${name()}!` : "Nobody yet."}</Text>
          <View style={{ flexDirection: "row", gap: 8 }}>
            <View onPress={() => nameInput?.focus()} {...button("accent")}>
              <Text style={buttonText("accent")}>Focus</Text>
            </View>
            <View onPress={() => (name.set(""), nameInput?.blur())} {...button()}>
              <Text style={buttonText()}>Clear</Text>
            </View>
          </View>
        </Card>
      </View>
    </Scroll>
  );
}

mount(App);

#Elements

The host elements are each backed by a native view. Rust makes them with the prelude’s functions; TSX imports them from nsh as components, and only If and Each are globals that need no import:

RustTSXNative viewPage
view()<View>UIView / ViewGroup: a box for layout, styling and touchesView
text(..)<Text>A native text view; nested text elements are spansText
image(src)<Image>An image view (local assets and URLs)Image
scroll()<Scroll>UIScrollView / ScrollViewScroll
input()<Input>UITextField / UITextView / EditTextInput
virtual_list(count, row)<List>A virtualized, recycling listList
canvas(..), web_view(url), video(url)<Canvas>, <WebView>, <Video>A drawing, a web page, a video playerElements
nsh_base::dialog()<Dialog> from nsh/baseA presented layerElements
when(..), each(..)<If>, <Each>Control flow, no view of its ownControl flow
use nsh_core::prelude::*;   // view, text, image, scroll, input, virtual_list, ..., when, each

In Rust, styles are typed methods named as Tailwind’s utilities are (.px_4(), .rounded_lg(), .bg(..)), and listeners are methods too (.on_press(..)); platform-only settings have methods of their own. In TSX, props keep their camelCase names (onPress, numberOfLines, backgroundColor); every element accepts style, class / classList (Tailwind), ref, touch handlers, accessibility props and testID, and platform-only props go in ios={{…}} and android={{…}}, where only the current platform’s object is sent:

view()
    .rounded(px(12.0))
    .bg(move || theme().background)
    .on_press(move || open.set(true))
    .child(text("Open").font_weight(FontWeight::SEMIBOLD))

TSX tags are PascalCase so that TypeScript checks each one against the binding it names: a lowercase tag is checked against one global type per name, so a generic component such as List or Each could not infer its rows’ type from each / of, and an app’s own components would not be typed at all. The imports cost nothing at run time: the compiler recognizes the primitives through their imports (aliases too) and makes the native element in place, so <View> is a direct call of the renderer and the import is dropped from the bundle. A lowercase tag is a build error that says what to write:

x lowercase tag <view>: tags are PascalCase components: write <View> and
| `import { View } from "nsh"`

#Components

The look of an app is the app’s own. NSH provides two layers: the primitives (the elements above, styles, Tailwind classes) and the unstyled base components of nsh/base (nsh_base in Rust), which own behavior such as navigation, dialogs, the keyboard, the status bar and safe areas. The third layer, the component library, is yours: buttons, cards and every other piece of your UI are components you write on those two layers, in your app’s crate (Building with Rust) or in TSX, or a library from the community (Three layers).

A Rust component is a #[component] fn that takes its props struct (and, when it has events, a Scope to emit them) and returns its elements. #[component] also makes its constructor, the function’s name in snake case: Badge is placed with badge(), as view() places a view, with a setter per prop (.label(..), .color(..): a value, a signal or a closure). A TSX component is a function that takes a props object and returns JSX:

#[derive(Debug, Clone, PartialEq, Props)]
pub struct BadgeProps {
    /// The text.
    pub label: Prop<SharedString>,
    /// The background (light blue when left out).
    #[prop(default = Hsla::from(rgb(0xddf4ff)))]
    pub color: Prop<Hsla>,
}

/// A small rounded label.
#[component]
pub fn Badge(props: BadgeProps) -> impl IntoElement {
    view()
        .rounded_full()
        .px(px(10.0))
        .py_1()
        .bg(move || props.color.get())
        .child(text(props.label).text_size(px(13.0)).font_weight(FontWeight::SEMIBOLD))
}

// Placed in another component's view:
badge()
    .label(move || format!("{} new", unread.get()))
    .color(rgb(0xfff8c5))

The function runs once, when the component is created, and does not track what it reads: there is no re-rendering. Code in its body (creating signals, starting effects, computing constants) runs one time. The elements it returns stay connected to the props and signals they read, so Badge above updates its text whenever unread changes, without running Badge again.

Write a #[component] when the UI has props or events of its own, or when a TSX entry imports it: a Rust component is registered under its type (registry.register::<Badge>()), with props typed from the struct (Building with Rust). For a piece of UI only Rust uses, a plain function returning impl IntoElement is enough, its arguments the props. In TSX, a component may return an element, a string or number, an array, null, or a function returning any of these (which the renderer follows); the Component<P> type from nsh describes one:

fn row(title: impl Into<SharedString>, content: impl IntoElement) -> impl IntoElement {
    view()
        .flex_row()
        .gap_2()
        .child(text(title.into()))
        .child(content)
}

#What runs again

A value given to an element is set once; a binding follows what it reads. In Rust a binding is a closure (or a signal, a memo, a prop) given where a value goes. In TSX, the compiler decides for each expression in JSX: an expression changes when it calls a function or reads a property (outside nested functions), and everything else is set once:

text(move || format!("{}", count.get()))     // follows count
text(props.title)                            // follows the prop
text(label.clone())                          // a plain value: set once
view().opacity(move || if busy.get() { 0.5_f32 } else { 1.0 })   // follows busy

A changing prop or child is a small native effect that sets that one prop or replaces that one child. Styles are diffed in Rust, so a style that changes only sends the properties that changed.

Event handlers are set once: a closure in Rust, a function or a name in TSX (onPress={() => …}, onPress={save}). In TSX, other expressions are followed (onPress={props.onPress} follows the prop).

#Props

A Rust component’s props are the fields of its #[derive(Props)] struct, each a Prop<T>: a value, or a binding the parent passed. Read one in a binding (text(props.title), move || props.title.get()) to follow it; props.title.get() in the body reads it once. Fields with #[prop(default)] or #[prop(default = …)], and Options, are optional. The constructor starts every prop at its default, and a parent calls the setters of the ones it needs: badge().label("New").

A TSX prop given a changing expression is passed as a getter: reading props.title calls it, which is what lets the JSX that reads it follow it. Static props are plain values. In both, props are read where they are used:

// Wrong: reads the prop once, when the component runs.
#[component]
fn Title(props: TitleProps) -> impl IntoElement {
    let content = props.text.get();
    text(content)
}

// Right: binds the text to the prop.
#[component]
fn Title(props: TitleProps) -> impl IntoElement {
    text(props.text)
}

A TSX component that takes its props apart in its parameter, function Title({ text }: …), reads them the same once, and the build warns of it with the props to read instead:

warn App.tsx:3:16: Title takes its props apart (`{ text }`): each is read once, when Title runs, and does not follow changes; take `props` and read `props.text` where it is used (`split(props, [...])` to pass the rest on)

A prop the component wants only once, such as an id it starts with, is read in the body (props.id.get_untracked(), const { id } = props;), which says so.

In TSX the same applies to default values: write props.color ?? "#ddf4ff" in the JSX rather than destructuring with a default. To compute something from props, use a closure or computed:

#[component]
fn Price(props: PriceProps) -> impl IntoElement {
    let formatted = move || format!("${:.2}", props.cents.get() as f64 / 100.0);
    text(formatted)
}

#Children

What a parent places in a component arrives as its children: a children: Prop<Children> field in Rust, which the parent fills with .child(..); props.children in TSX, a getter that builds those elements when read:

#[derive(Debug, Clone, PartialEq, Props)]
pub struct CardProps {
    /// The title.
    pub title: Prop<SharedString>,
    /// The content.
    pub children: Prop<Children>,
}

#[component]
pub fn Card(props: CardProps) -> impl IntoElement {
    view()
        .p(px(14.0))
        .rounded(px(12.0))
        .bg(move || theme().background)
        .gap_2()
        .child(text(props.title).font_weight(FontWeight::SEMIBOLD))
        .child(props.children)
}

card()
    .title("Account")
    .child(text(move || format!("Signed in as {}", user.get().name)))

In TSX, each read of props.children builds the children again (new native views): read it once, in the place where the children go. Among several children, one that changes (an <If>, an <Each>, {cond() ? a : b}) arrives as a function, and {props.children} updates that one alone: its siblings keep their views, an input its text and focus. Fragments (<>…</>) group elements without a view.

Children can also be made by a function the component calls with arguments, a render callback:

fn repeat<E: IntoElement>(times: usize, row: impl Fn(usize) -> E) -> impl IntoElement {
    view().child((0..times).map(row))
}

repeat(3, |i| text(format!("Row {i}")))

#Control flow

when and each in Rust, <If> and <Each> in TSX, are built-in control flow. They render no view of their own.

when(move || user.get().is_some(), move || {
    text(move || format!("Hello, {}", user.get().map(|u| u.name).unwrap_or_default()))
})
.otherwise(|| text("Signed out"))

each(todos, |todo| todo.id, |todo, index| {
    text(move || format!("{}. {}", index.get() + 1, todo.get().title))
})
  • when / <If> shows its branch while the condition holds, else the otherwise / else branch. It renders a branch again only when whether the condition holds changes, not every time the condition is re-evaluated. In TSX, a function child gets an accessor of when’s value, typed without null and undefined: u() is the user while the branch shows, and follows it when it changes.
  • each / <Each> renders a row per item, made by its row function. Rows are kept by key (Rust: the key function; TSX: key, by default the item itself, objects by identity and primitives by value): when the list changes, kept rows are moved rather than rebuilt, and new rows are made only for new keys. The row function receives signals of the item and its index (todo.get() and index.get() in Rust, todo() and index() in TSX), because a kept row’s item and position can change. In TSX, key="index" (or key={(item, i) => i}) keeps rows by position instead: a list of strings that changes in place, such as text streamed in, keeps its rows and only their item() changes.

A choice made in a closure (dynamic(move || if busy.get() { … } else { … }) in Rust, a ternary or && in TSX) builds its branch again every time anything it reads changes. Prefer when / <If> when a branch is more than a few elements.

For long or infinite lists, use List (virtual_list in Rust): it creates views only for the rows on screen and recycles them. See Control flow for the details.

#Refs

A ref names a host element for imperative calls: focus(), blur(), scroll_to() / scrollTo(), scroll_to_index() / scrollToIndex(), measure() (where a view is), … In Rust, element_ref() makes one and .element_ref(&handle) puts it on an element; in TSX, ref gives you the element’s native node, a YNode:

fn search() -> impl IntoElement {
    let field = element_ref();
    view()
        .child(input().element_ref(&field).placeholder("Search"))
        .child(text("Focus").on_press(move || field.focus()))
}

A Rust ref is a Copy handle; its calls go to the element that carries it, and do nothing once its owner is gone. In TSX, what ref={…} names receives the node:

  • a variable declared with let is assigned it;
  • a field (ref={refs.input}, ref={refs[name]}) is assigned it;
  • a signal is set to it, so what reads the signal follows it;
  • a function is called with it (<Scroll ref={(node) => (scroller = node)} />).

A const or an import holds a signal or a function, as in const input = signal<YNode>() with <Input ref={input} /> and input()?.focus(); one holding anything else is reported when the element is made, with what to write.

The ref is set when the element is created, before the component returns, so it is available in effects and event handlers. Calls are sent to the view in order with the changes made before them. The base components and the Rust components of your own crate accept ref too (in Rust component_ref(), put on the element with .component_ref(&r): dialog().component_ref(&r)).

#Spreading props

In Rust, a parent calls the setters of the props it needs and the rest keep their defaults, and a wrapper passes its own Props on as they are (card().title(props.title)): a Prop keeps following what it was given.

In TSX, spread an object of props onto an element or a component with {...props}. Getters stay getters, so spread props keep following what they read. To take some props for yourself and pass the rest on, use split:

import { split, Text, View } from "nsh";
import { Input, type InputProps } from "nsh/base";

function Field(props: InputProps & { label: string }) {
  const [own, rest] = split(props, ["label"]);
  return (
    <View style={{ gap: 4 }}>
      <Text>{own.label}</Text>
      <Input style={{ borderWidth: 1, borderRadius: 8, padding: 8 }} {...rest} />
    </View>
  );
}

<Field label="Email" value={email()} onChange={(t) => email.set(t)} keyboardType="email-address" />

split(props, keys) returns [picked, rest], both still reading through to the original props (destructuring { label, ...rest } = props would read every prop once). Element props that come later in the JSX win over earlier ones.

<If> and <Each> take no spread props, and spread children ({...items}) are not supported: use <Each> or an array expression.

#Tags and names

A Rust element is whatever the code calls: a primitive’s function, a component’s constructor (badge(), nsh_base::button()), or a function of your own. A TSX tag names a binding in scope, as usual: <Badge />, <Theme.Provider>, a <Button> of your own. A tag that names nothing in scope is a build error, except <If> and <Each>, which are globals.

/// The app's button: a press target styled by the app.
fn primary_button(title: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
    view()
        .role(AccessibilityRole::Button)
        .rounded_lg()
        .bg(rgb(0x2563eb))
        .px_5()
        .py_3()
        .active(|s| s.opacity(0.6))
        .on_press(on_press)
        .child(text(title).text_base().font_weight(FontWeight::SEMIBOLD).text_color(white()))
}

primary_button("Save", save)

nsh_base’s Button (button() in Rust, <Button> from nsh/base) is the unstyled press target with the disabled and selected states and their accessibility; the app gives it its look: button().disabled(move || saving.get()).on_press(move || save()).px_4().active(|s| s.opacity(0.6)).child(text("Save")).

In TSX, your bindings win over the globals: a component named Each in scope makes <Each> that component. A primitive is recognized by its import, so import { View as Box } from "nsh" makes <Box> a view, and a component of your own named Text is just your component. The primitives are real components too: const Tag = inline ? Text : View; and then <Tag>…</Tag> works.

Components are typed by their own declarations: Rust’s props structs are checked by the compiler, and TypeScript checks a TSX component’s props wherever it is used, with nothing to generate. A Rust component a TSX entry imports gets TypeScript declarations generated from its struct.

#Mounting and the root

A Rust app’s root is the component nsh.json’s "root" names; nsh run and nsh package mount it. A TSX entry calls mount(App) to render the root component (mount(App, props) passes it props). The root view fills the surface (the window, or the view an app embeds NSH in). Give the root flex: 1 so it fills the screen, and a background color: the surface’s own background depends on the platform.

// nsh.json: { "rust": ".", "root": "App" }
#[component]
pub fn App() -> impl IntoElement {
    view()
        .flex_1()
        .bg(move || theme().background)
        .child(home())
}