Elements

If / Each

If and Each decide which elements exist: conditional and repeated content. They make no native view of their own. The native renderer updates only what changed: If swaps a branch when its condition flips, Each adds, moves and removes rows by key instead of rebuilding the list. Rust has when(..), each(..) and dynamic(..) in nsh_core::prelude; TSX has <If> and <Each>, globals that need no import.

el-control-flow.tsx
el-control-flow.tsxLive
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, focusRing, hoverFill, Inspector, Panel, size, Stage, ui, wide } from "./palette";

interface Todo {
  id: number;
  title: string;
  done: boolean;
}

let nextId = 4;

function App() {
  const todos = signal<Todo[]>([
    { id: 1, title: "Read the elements page", done: true },
    { id: 2, title: "Try <If> and <Each>", done: false },
    { id: 3, title: "Ship the app", done: false },
  ]);
  const showDone = signal(true);
  const visible = () => (showDone() ? todos() : todos().filter((t) => !t.done));
  const toggle = (id: number) => todos.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  const add = () => todos.update((list) => [...list, { id: nextId++, title: `Task ${nextId - 1}`, done: false }]);
  const remove = (id: number) => todos.update((list) => list.filter((t) => t.id !== id));
  const actions = () => (
    <View style={{ flexDirection: "row", gap: 8 }}>
      <View onPress={add} {...button("accent")}>
        <Text style={buttonText("accent")}>Add</Text>
      </View>
      <View onPress={() => showDone.set(!showDone())} {...button()}>
        <Text style={buttonText()}>{showDone() ? "Hide done" : "Show done"}</Text>
      </View>
    </View>
  );
  const list = () => (
    <If
      when={visible().length > 0}
      else={<Text style={{ color: ui().muted, textAlign: "center", marginTop: 40 }}>Nothing to show.</Text>}
    >
      <View style={{ gap: wide() ? 6 : 8 }}>
        <Each of={visible()} key={(t: Todo) => t.id}>
          {(todo, index) => (
            <View
              style={{
                ...ui().row,
                flexDirection: "row",
                alignItems: "center",
                gap: 12,
                ...(wide() ? { borderRadius: 8, height: size.row, paddingHorizontal: 12 } : { borderRadius: 12, padding: 12 }),
              }}
            >
              <View
                onPress={() => toggle(todo().id)}
                hitSlop={8}
                style={{
                  width: wide() ? 18 : 22,
                  height: wide() ? 18 : 22,
                  borderRadius: wide() ? 9 : 11,
                  borderWidth: 2,
                  borderColor: ui().accent,
                  backgroundColor: todo().done ? ui().accent : "transparent",
                }}
                hoverStyle={{ opacity: 0.88 }}
                focusStyle={focusRing()}
                activeStyle={{ opacity: 0.7 }}
              />
              <Text
                style={{
                  flex: 1,
                  fontSize: wide() ? size.label : undefined,
                  color: todo().done ? ui().faint : ui().text,
                  textDecorationLine: todo().done ? "line-through" : "none",
                }}
              >
                {index() + 1}. {todo().title}
              </Text>
              <If when={todo().done}>
                <View onPress={() => remove(todo().id)} hitSlop={8} hoverStyle={hoverFill()} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
                  <Text style={{ color: ui().down, fontSize: wide() ? 13 : undefined }}>Remove</Text>
                </View>
              </If>
            </View>
          )}
        </Each>
      </View>
    </If>
  );
  const counts = () => `${todos().filter((t) => t.done).length} of ${todos().length} done`;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page }}>
      {() =>
        wide() ? (
          <View style={{ flex: 1, padding: 20, paddingTop: 24, gap: 14 }}>
            <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>if / each</Text>
            <Text style={{ color: ui().muted }}>Click a circle to finish a task; Remove shows on finished ones</Text>
            <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
              <Stage width={420}>
                {/* A fixed height, so the list grows downwards instead of moving. */}
                <View style={{ height: 360 }}>{list()}</View>
              </Stage>
              <Inspector>
                {actions()}
                <Panel title="todos · showDone">
                  <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{counts()}</Text>
                  <Text style={{ fontSize: 13, color: ui().text }}>
                    {showDone() ? "every task shown" : `${visible().length} not done shown`}
                  </Text>
                </Panel>
              </Inspector>
            </View>
          </View>
        ) : (
          <View style={{ flex: 1, width: "100%", maxWidth: 560, alignSelf: "center", padding: 20, paddingTop: 24, gap: 12 }}>
            <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>if / each</Text>
            {actions()}
            {list()}
            <Text style={{ fontSize: 12, color: ui().muted, fontVariant: ["tabular-nums"] }}>{counts()}</Text>
          </View>
        )
      }
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

view()
    .child(
        when(move || user.with(|u| u.is_some()), move || {
            text(move || format!("Hello, {}", user.with(|u| u.as_ref().map(|u| u.name.clone()).unwrap_or_default())))
        })
        .otherwise(|| text("Signed out")),
    )
    .child(each(todos, |t: &Todo| t.id, |todo, _index| text(move || todo.get().title)))

In TSX they are globals (a binding of the same name in scope takes their place). Inside JSX expressions you can still use plain JavaScript (cond ? a : b, items.map(…)), and in Rust dynamic(move || ..): those make their elements again every time what they read changes, while If and Each (when and each in Rust) keep the elements that stay.

#If

Renders its branch while the condition holds, else the other branch (or nothing).

PropRustTypeDescription
whenwhen(cond, ..)TThe condition: a value, a signal or a closure in Rust; in TSX its truthiness.
else.otherwise(|| ..)JSX.ElementRendered while when does not hold.
childrenwhen(.., || ..)JSX.Element | (value: () => NonNullable<T>) => JSX.ElementRendered while when holds. In TSX, a function gets an accessor of when’s value.

A branch is made when the condition flips to it, and what it made goes when it flips away. A TSX when that goes from one truthy value to another (user() changing from one object to another) keeps the branch, and the expressions inside it update on their own.

let loading = signal(true);

when(move || !loading.get(), || profile())
    .otherwise(|| text("Loading…").text_color(rgb(0x6e7781)))

In TSX, a function child reads the value when gave, without null or undefined in its type, so the branch needs no !. In Rust, the branch reads the signal itself, or dynamic maps the value to elements:

let user = signal(None::<User>);

dynamic(move || user.get().map(|u| text(u.name)))

#Each

Renders a row for each item of a list.

PropRustTypeDescription
ofeach(items, ..)readonly any[] | null | undefinedThe items: in Rust a Vec, a signal of one or a closure returning one. null and undefined render nothing.
keyeach(.., |item| key, ..)(item: any) => unknownThe identity of an item. TSX’s default: the item itself (objects by identity, primitives by value).
childreneach(.., .., |item, index| row)(item: () => any, index: () => number) => JSX.ElementRenders one row.

The row function gets accessors, not values: call item() and index() in TSX, item.get() and index.get() in Rust (both are ReadSignals). When the items change, rows whose key is still there are kept (and moved if their position changed); their item and index update, and only the expressions reading them run again. New keys get new rows; rows whose key is gone are removed.

#[derive(Clone, PartialEq)]
struct Todo {
    id: u32,
    title: String,
    done: bool,
}

let todos = signal(Vec::<Todo>::new());

each(todos, |t: &Todo| t.id, |todo, index| {
    view()
        .flex_row()
        .gap_2()
        .child(text(move || format!("{}.", index.get() + 1)))
        .child(
            text(move || todo.get().title)
                .style_when(move || todo.get().done, |s| s.line_through()),
        )
})

#Choosing a key

  • Items replaced by new objects on every update (immutable updates, { ...t, done: true }) need a key, usually an id. Without it each new object is a new row and its elements are made again. Rust always takes a key.
  • In TSX, arrays of strings or numbers work without a key: they are compared by value.
  • Each row has its own owner: effects and cleanups created in a row end when the row is removed.

#Each or a list

Each creates every row. That is right for tens or a few hundred items, in a view or a scroll view. For long or unbounded data, use a list: it creates the rows near the viewport and recycles them.

See JSX and components and reactivity for how expressions update.