API

nsh

nsh is the core module: the reactive API (signals, computed values, effects, context), mount, the host elements View, Text, Scroll and Input, and the TypeScript types of elements, styles and events. The reactive graph itself runs natively in Rust; reading a signal is a call into the runtime that costs a few nanoseconds, and only your own closures run in the JavaScript interpreter.

import { batch, cleanup, computed, context, effect, Input, mount, Scroll, signal, split, Text, untracked, View } from "nsh";

nsh’s reactivity follows the same model as SolidJS: a component function runs once, and the parts of the UI that read signals are updated in place when those signals change. There is no re-render of the component and no virtual DOM. See Reactivity for the guide.

#signal

function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
function signal<T = undefined>(): Signal<T | undefined>;

interface Signal<T> {
  (): T;                              // read (tracked)
  set(value: T): void;                // write
  update(fn: (value: T) => T): void;  // write from the current value
}

interface SignalOptions<T> {
  /** When a new value counts as a change (default `===`); `false`: always. */
  equals?: false | ((previous: T, next: T) => boolean);
}

A signal holds a value. Calling it reads the value and, inside a tracking scope (JSX expressions, computed, effect), subscribes that scope to it. set and update write it; everything that read it updates.

import { mount, signal, Text, View } from "nsh";

function Counter() {
  const count = signal(0);
  return (
    <View style={{ padding: 24, gap: 12 }}>
      <Text>Count: {count()}</Text>
      <Text onPress={() => count.update((n) => n + 1)}>Add one</Text>
    </View>
  );
}

mount(Counter);

By default a write that is === to the current value does nothing. Pass equals: false to notify on every write (useful when you mutate an object in place and set the same object again), or your own comparison:

const items = signal<string[]>([], { equals: false });
const point = signal({ x: 0, y: 0 }, { equals: (a, b) => a.x === b.x && a.y === b.y });

#computed

function computed<T>(fn: (previous: T | undefined) => T, options?: SignalOptions<T>): Accessor<T>;

interface Accessor<T> {
  (): T;
}

A value derived from signals. fn runs again when a signal it read changes; readers of the computed value only update when its result changes (by equals, === by default). fn receives the previous result.

const first = signal("Ada");
const last = signal("Lovelace");
const full = computed(() => `${first()} ${last()}`);
full(); // "Ada Lovelace"

Use computed when a derivation is expensive or read in several places. For a cheap expression, a plain function (const full = () => first() + " " + last()) is just as reactive.

#effect

function effect(fn: () => void): void;

Runs fn after rendering, and again whenever a signal it read changes. Use effects for side effects: logging, timers, talking to native modules. An effect belongs to the component (or effect) it was created in and is disposed with it.

const query = signal("");
effect(() => {
  console.log("query is now", query());
});

To undo something before the effect runs again (or when its component goes away), call cleanup inside it.

#batch

function batch<T>(fn: () => T): T;

Runs fn and applies all its writes together at the end, so readers update once instead of once per write. Returns what fn returns.

batch(() => {
  first.set("Grace");
  last.set("Hopper");
}); // `full` recomputes once

#untracked

function untracked<T>(fn: () => T): T;

Runs fn without subscribing the current scope to what it reads. Use it to read a signal inside an effect or JSX expression without depending on it.

effect(() => {
  // Runs again when `query` changes, not when `page` changes.
  search(query(), untracked(() => page()));
});

#cleanup

function cleanup(fn: () => void): void;

Registers fn to run before the current effect runs again, or when the current component or effect goes away.

function Clock() {
  const now = signal(new Date());
  const timer = setInterval(() => now.set(new Date()), 1000);
  cleanup(() => clearInterval(timer));
  return <Text>{now().toLocaleTimeString()}</Text>;
}

#context

function context<T>(defaultValue: T): Context<T>;

interface Context<T> extends Accessor<T> {
  Provider: (props: { value: T; children?: JSX.Element }) => JSX.Element;
}

A value a component provides to everything it renders, however deep. Calling the context (Theme()) reads the nearest provider’s value, or defaultValue without one.

import { context, mount, signal, Text, View } from "nsh";

const Theme = context<"light" | "dark">("light");

function Label(props: { text: string }) {
  return <Text style={{ color: Theme() === "dark" ? "#fff" : "#000" }}>{props.text}</Text>;
}

function App() {
  const dark = signal(false);
  return (
    <Theme.Provider value={dark() ? "dark" : "light"}>
      <View style={{ flex: 1, padding: 24, backgroundColor: dark() ? "#000" : "#fff" }}>
        <Label text="Hello" />
        <Text onPress={() => dark.update((d) => !d)}>Toggle</Text>
      </View>
    </Theme.Provider>
  );
}

mount(App);

#mount

function mount<P extends object>(app: Component<P>, props?: P): () => void;

Renders app into the surface (the window, or the NshAppView / NshView the app is embedded in) and returns a function that removes it. Every app’s entry file ends with mount(App).

mount(App);

#split

function split<T extends object, K extends readonly (keyof T)[]>(
  props: T,
  keys: K,
): [Pick<T, K[number]>, Omit<T, K[number]>];

Splits a props object into the props named in keys and the rest. Both halves stay reactive: props whose values change are getters, and split keeps them as getters. Use it in a component that handles some props itself and passes the others on. Destructuring props (const { label } = props) would read them once and lose updates.

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

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

#View, Text, Scroll, Input

function View(props: ViewProps): YNode;
function Text(props: TextProps): YNode;
function Scroll(props: ScrollProps): YNode;
function Input(props: InputProps): YNode;

The host elements nsh exports, written as tags: <View>, <Text>, <Scroll>, <Input>. The compiler makes the native view in place for a tag that names one of them through its import (an alias too), so importing them costs nothing at run time. They are components like any other, so they can also be passed around (const Tag = inline ? Text : View). The other elements come from their own modules: Image from nsh/image, List from nsh/list, Canvas, WebView and Video from theirs. Their props are documented under Elements.

#Types

nsh also exports the types of the reactive API and of the host elements. The most used ones:

TypeWhat it is
Accessor<T>A readable value: () => T
Signal<T>An Accessor<T> with set and update
SignalOptions<T>Options of signal and computed (equals)
Context<T>What context returns
Component<P>(props: P) => JSX.Element
JSXThe JSX namespace (JSX.Element)
YNodeWhat a ref receives: focus, scroll and other commands. See Elements
ViewProps, TextProps, ImageProps, ScrollProps, InputProps, CommonProps, TouchPropsProps of host elements
ViewStyle, TextStyle, ImageStyle, LayoutStyle, StyleProp<T>Style objects; see the style reference
ColorValue, DimensionValue, LengthValue, TransformStepValues inside styles
GestureResponderEvent, LayoutEvent, ScrollEvent, PanEvent, TouchPointEvent payloads
ImageAssetWhat importing an image file gives ({ uri, width, height })
OS"ios" | "android" | "macos" | "windows" | "linux"
AccessibilityRole, AccessibilityStateAccessibility props
import { type GestureResponderEvent, type YNode, signal } from "nsh";

let field: YNode | undefined;
const onPress = (e: GestureResponderEvent) => field?.focus();

StyleProp<T> accepts one style or a list merged left to right, skipping falsy entries:

<View style={[styles.card, selected() && styles.selected]} />