Skip to content

Functions & Components

NX separates let bindings from component declarations. Use let for values and reusable functions. Use component when a declaration needs an emits contract or persistent state.

Runtime note: action declarations parse, lower, and behave like records. Component init returns rendered output plus an opaque host-owned state snapshot. Dispatch consumes that snapshot together with a host-ordered batch, applies the component’s State.Update records to its state, and returns the re-rendered output, the effect actions, and the next snapshot. See Updating state.

type User = { name:string email:string avatarUrl:string }
let <UserCard user:User className:string = "card"/> =
<div className={className}>
<img src={user.avatarUrl} alt="User avatar"/>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
let currentUser = <User name="Ada" email="ada@example.com" avatarUrl="/ada.png"/>
// Later in the module
<UserCard user={currentUser} className="featured"/>
  • Attributes in the definition carry type annotations.
  • Default values use = just like standard attributes. A call that leaves out className gets "card".
  • Invocation reuses the same structure but supplies values instead of types.
  • An element-style function is called only as an element. UserCard(currentUser) is rejected: its attributes have no order a caller could rely on.

An external declaration states a runtime-provided function’s signature without an NX body. The visibility modifier comes first, and the result type is required:

/// Returns the sine of x, measured in radians.
/// Rejects non-finite inputs and preserves negative zero.
export external sin(x:float64):float64

An external function is executable only when the compiler’s trusted module registry supplies a matching implementation. Currently this is used by the built-in @nx/math library. Writing the same declaration in an application or a host-loaded library reports an unregistered-implementation diagnostic; it does not register a callback or an FFI binding.

The compiler checks the registered parameter names, order, types, calling form and result type. Registered math parameters are required float64 values without defaults or content modifiers. An external function cannot supply = expression; an ordinary function still requires its body. The element signature form is also valid syntax, such as external <Name x:float64 />:float64, but requires its own matching registered implementation.

Put /// documentation immediately before the declaration, and parameter documentation before its parameter in a multiline signature. These comments feed hover and declaration completions. Normal calls use the imported declaration:

import "@nx/math" as math
let root():float64 = { math.sin(math.pi / 2.0) }
type GridRow = { cells:string+ }
let <DataGrid
headers:string+
rows:GridRow+
className?:string /> =
<table className={className ?? "data-grid"}>
<thead>
<tr>
for header in headers {
<th>{header}</th>
}
</tr>
</thead>
<tbody>
for row in rows {
<tr>
for cell in row.cells {
<td>{cell}</td>
}
</tr>
}
</tbody>
</table>
  • An optional prop is marked on its name (className?:string) and reads as string? inside the body, so it takes a fallback with ?? or a presence test before it is used as one value.
  • A default can read the parameters declared before it, and anything its own module can see.
  • Iteration and conditionals in the body behave like any other expression.
let <Dialog title:string content body:Element /> =
<section>
<h2>{title}</h2>
{body}
</section>
  • Prefix one property with content to declare where element body content binds.
  • The marker is contextual: content remains a normal identifier outside property-definition positions.
  • The same inline form works for element-style let, paren-style let, component props, emitted-action payloads, and component state.
action SearchSubmitted = {
searchString:string
}
  • action uses the same record-style field syntax as type Name = { ... }.
  • Actions remain record-compatible, so existing record construction paths keep working.
  • The distinction matters only for contexts that explicitly require actions.
component <SearchBox
placeholder:string
emits {
ValueChanged {
value:string
}
SearchSubmitted
}
/> = {
state {
query:string = {placeholder}
}
<TextInput value={query} placeholder={placeholder} />
}
<SearchBox placeholder="Find docs" />
  • The signature keeps the element-style prop syntax.
  • emits can mix inline action definitions (ValueChanged { ... }) with references to existing actions (SearchSubmitted).
  • Inline emitted actions become public action names such as SearchBox.ValueChanged.
  • Call sites can bind handlers with on<ActionName> and read the emitted payload through the implicit action value.
  • state declares persistent local fields before the rendered body expression.
  • State defaults are evaluated once during initialization. After that, state changes only through the component’s state update record, <State.Update ... /> (see Updating state).
  • Hosts own the serialized snapshot returned by initialization and must pass it back into later dispatch calls.
  • Dispatch preserves host action order and returns effect actions in the same order handlers produce them.
  • Update, Property, State and Props are reserved: a component cannot emit an action named Update, Property, State or Props. <Component>.State is its state record and <Component>.Props its props record, and <Component>.Update and <Component>.Property are kept free because they read as derived declarations. A prop or state field cannot be named State either; a prop named Props is fine, since there is no bare Props inside a component.

A call site that uses the SearchBox above binds a handler per emitted action, and a function can build an emitted action by its public name:

action DoSearch = {
search:string
}
action TrackSearch = {
value:string
}
let makeValueChanged(value:string): SearchBox.ValueChanged =
<SearchBox.ValueChanged value={value} />
<SearchBox
placeholder="Find docs"
onSearchSubmitted=<DoSearch search={action.searchString}/>
onValueChanged=<TrackSearch value={action.value}/> />

A component that draws a collection through a template needs to say that its items and its template agree on one item type without fixing what that type is. A type parameter does that. It is declared with the same name:type syntax as a prop, using the keyword type as the type, and it must come first in the signature, after extends and before every prop:

type Contact = { name:string }
external component <SkiaLayout
TItem:type
itemsSource?:TItem+
content children?:object+
/>
let contacts:Contact* = {}
<SkiaLayout TItem=Contact itemsSource={contacts} />
  • Inside the signature and the body, TItem is a type like any other: itemsSource?:TItem+ and a state field first?:TItem both work. It is distinct from every other type, including a same-named type declared outside the component, which it shadows.
  • A use site supplies the type by name, as a bare type name: TItem=Contact. Any visible type qualifies — a record, a union, an alias, a primitive, or a type parameter of the enclosing component (TItem=TItem forwards it). Braced, quoted, and conditional forms are rejected.
  • Leave it out when nothing needs it. A use site that binds only children writes <SkiaLayout>...</SkiaLayout> and no argument. A prop typed by an unspecified parameter accepts only the empty value {}; binding anything else reports that TItem was not specified and shows the TItem= form to add.
  • A type parameter is not a prop. It carries no value, has no default, is never required, is not a field of the runtime record, and is not a case of the state’s property union. <Component>.State and the state’s own update record erase it. <Component>.Props keeps it, as a generic record does: <List.Props TItem=int/>. Once type checking has consumed a TItem=Contact binding, nothing below the checker sees it.
  • An emitted action’s payload cannot be typed by a type parameter. emits { pick { item:TItem } } is rejected: the action is a record of its own, usable outside the component, where TItem is not a type.
  • A derived component inherits its abstract base’s type parameters open, ahead of its own, and cannot redeclare one. <ContactList extends ItemsBase /> supplies TItem exactly as <ItemsBase /> would.
  • Name a type parameter T or a T-prefixed PascalCase name: TItem, TKey, TValue. A primitive type name such as string or object, and the built-in Element, are rejected; any other name is allowed and shadows a same-named declared type inside the component.

Type parameters are supported on component signatures and on plain type record declarations; an action, state group, function parameter list, function type, alias and union cannot declare one. A record’s parameters differ from a component’s in one way that matters: they are not erased, so <Range T=int/> and <Range T=float64/> are different types. See Generic records.

Every record and action has a derived update record, T.Update, with the same fields as T, every one optional. A component is not a record, so it has none of its own; its props record and its state record do (see Component props and state). An update record is a patch: a field it leaves out means “unchanged”, and a field set to the empty value {} means “cleared”, which is allowed only where the field is optional in T (email?:string). Constructing one applies no defaults and requires nothing.

type User = { name:string = "anon" email?:string }
let rename = <User.Update name="Ada" /> // only `name`; `email` is untouched
let clearEmail = <User.Update email={} /> // `email` is cleared
let nothing = <User.Update /> // a valid, empty patch
  • T.Update includes fields T inherits, and cannot itself be extended; there is no T.Update.Update.
  • An update record is an ordinary value: store it, pass it, return it, or send it to a server as the set of changes to apply. On the wire it carries $type: "User.Update" and only the fields that are present.
  • A list field is replaced whole.

Every component has a derived props record, T.Props, whose fields are its props, inherited props first, with their types, optional marks and content modifiers and without their defaults. A component with state also has a derived state record, T.State, whose fields are its state fields in order, again with their types and optional marks and without their defaults. A default may read other props, which a record written outside the component does not have, so neither record keeps one.

Each is a plain record like any other, so each has its own update record, T.Props.Update and T.State.Update, and property union, T.Props.Property and T.State.Property:

component <Card title:string subtitle?:string /> = { <Panel /> }
let saved = <Card.Props title="Draft" />
let rename = <Card.Props.Update title="Final" />
let key: Card.Props.Property = {Card.Props.Property.subtitle}
let root() = {apply(saved, rename)}
<Card.Props title="Final" />
  • T.Props has one field per prop and nothing else: no type parameter, state field or emitted action. A component with no props has an empty one, so <Divider.Props /> is a value.
  • Because it keeps no defaults, a T.Props value gives every prop that is not optional, even one the component defaults: <Counter.Props /> is rejected when Counter declares step:int = 1.
  • Neither record can be extended, and only a component has them. A component without state, a record, T.State and T.Props themselves, T.Update and T.Property all report .State or .Props as unknown.
  • A component’s own T.Update and T.Property are unknown too, reported with a suggestion to write T.State.Update or T.State.Property when it has state, and T.Props.Update or T.Props.Property when it has none.
  • typegen emits no host type for T.Props yet, and reports an exported declaration whose generated type would refer to one.

Inside a component body, a bare State names that component’s state record, even if the module declares something else called State. It works as a type (saved?:State), as a tag (<State.Update count=0 />), and as the head of a member access ({State.Property.count}). Outside a component, write the qualified form, Counter.State.Update. Update and Property are ordinary names inside a component; a bare one that resolves to nothing is reported with a suggestion to write State.Update or State.Property. There is no bare Props: the props record is always written qualified, Counter.Props, inside the component as well.

external component <Button label:string emits { Tapped { } } />
action Reset = { }
component <Counter step:int = 1 emits { Reset } /> = {
state { count:int = 0 }
<Row>
<Label text={count} />
<Button label="Add" onTapped=<State.Update count={count + step} /> />
<Button label="Reset" onTapped={<State.Update count=0 /> <Reset />} />
</Row>
}

A handler’s result is routed by type:

  • The enclosing component’s state update record (Counter.State.Update) patches that component’s state.
  • An action the enclosing component emits (Reset, declared or inherited in emits) goes to its parent.
  • Anything else inside a component is a compile error: add the action to emits, or return the component’s state update record. At the root there is no component to patch or parent to reach, so any action or update record is an effect for the host.

A handler may return one value or a list of them, mixed freely; an empty list is an error. Handler bodies are type checked with everything their binding site can see — the component’s props and state, enclosing let bindings and loop variables — plus action, typed by the emitted action.

State reads inside a handler are live. When dispatch runs a handler, a state field reads the value it has at that moment, including patches applied earlier in the same batch, so tapping the counter twice in one batch moves it by two. Props, let bindings, and loop variables keep the values they had when the handler was created.

Dispatch runs a batch in order, applies each state update record for the component to its state, collects everything else as effects, and re-renders once at the end. Rendered output from initialization and dispatch identifies each bound handler by a token; a host runs one by dispatching { $type: "ActionHandlerInvocation", token, action } with the snapshot returned alongside that output. A token is valid only with that snapshot: every dispatch, even one whose batch is empty or produces only effects, returns fresh tokens and retires the previous ones. Pure evaluation, which has no snapshot to dispatch against, renders handlers without tokens. A handler that was bound outside the component — at the root, or by a parent whose output the component renders — can still be dispatched by its token, but it reads only what it captured and everything it returns is an effect. The batch is atomic: if any entry fails — an unknown or stale token, a handler error, an invalid update — dispatch fails as a whole and the snapshot the host supplied remains the current state. Everything the host passes in — props, explicit state, and the actions in a batch, whether or not a handler is bound for them — is checked against its declaration at every depth, so an update record nested in a prop, a component value, an array, or an action payload meets the same unknown-field and cleared-field rules as one the type checker saw.

Every record and action also has a derived property union, T.Property: a constant union with one case per field of T, inherited fields first. A component has none of its own; its props have T.Props.Property and its state T.State.Property. A case names a field as a value, so a sort key, a column list, or a validation rule is typed by the fields that exist rather than by string.

type Contact = { title:string subtitle:string }
external component <Table sortBy:Contact.Property columns:Contact.Property+ />
let table = <Table sortBy=subtitle columns={ Contact.Property.title Contact.Property.subtitle } />
let key: Contact.Property = {Contact.Property.title}
  • T.Property behaves as any constant union: a bare case resolves at a site typed T.Property, if key is { title => "Title" subtitle => "Subtitle" } is checked for exhaustiveness, and on the wire a case is the bare field name, "subtitle".
  • T.Property includes the fields T inherits, cannot be extended, and has no derived declarations of its own; nor does T.Update. Two property unions are distinct types even when their cases share names.
  • Inside a component, State.Property names the component’s state property union. Outside a component, write the qualified form, Counter.State.Property.

Four intrinsic functions work on update records without naming a record type. Their names are reserved: they resolve before anything in scope, cannot be shadowed, and a declaration named apply, merge, diff, or changed is rejected.

Call Result
apply(record, update) T: the record with each present field of the update replaced.
merge(first, second) T.Update: every field present in either, the later one winning.
diff(before, after) T.Update: exactly the fields whose values differ, taken from after.
changed(update) T.Property*: the present fields, in declaration order.

Every intrinsic keeps the absent-versus-empty rule: a present empty field is carried and an absent field is never invented, so apply(u, merge(a, b)) equals apply(apply(u, a), b) and apply(a, diff(a, b)) equals b. apply and diff take a record, so they work on a component’s T.State value but not on a component element; merge and changed take any update record.

external component <Button label:string emits { Tapped { } } />
component <Counter step:int = 1 /> = {
state { count:int = 0 last:State.Property = {State.Property.count} }
<Row>
<Label text={count} />
<Button label="Add" onTapped=<State.Update count={count + step} last={State.Property.count} /> />
</Row>
}
let touched(patch:Counter.State.Update): Counter.State.Property* = {changed(patch)}

A bare identifier that names a visible function — element or paren style, declared in the same file, a same-library peer, or an import — is a value of that function’s type. Bind it to a function-typed property or value by name:

abstract external component <DrawnNode />
type Contact = { name:string }
type RowTemplate = <function Item:Contact Index:int />: DrawnNode
external component <List extends DrawnNode
TItem:type
ItemsSource?:TItem+
ItemTemplate?:<function Item:TItem Index:int />: DrawnNode
/>
external component <Label extends DrawnNode Text?:string />
let <ContactRow Item:Contact Index:int />: DrawnNode = <Label Text={"" + Index + " " + Item.name} />
let <Compact Item:Contact />: DrawnNode = <Label Text={Item.name} />
let contacts = { <Contact name="Ada" /> <Contact name="Kai" /> }
let full = <List TItem=Contact ItemsSource={contacts} ItemTemplate={ContactRow} />
let short = <List TItem=Contact ItemsSource={contacts} ItemTemplate={Compact} />
let rows: RowTemplate+ = { ContactRow Compact }

A function value is a reference to its declaration and captures nothing: functions are module-level and a body declares nothing of its own. A runtime renders one as the record { "$type": "Function", "module": "<module>", "name": "ContactRow" }, which a host — a list that recycles its cells, say — calls once per item. A lexical binding of the same name shadows the function, as it shadows any top-level name.

A parameter, prop or local let whose type is a function type is invoked as an element. Arguments bind to the type’s parameters by name, every parameter of the type is required, and the call has the type’s result type. Continuing the example above:

component <Highlight extends DrawnNode Item:Contact Row:RowTemplate /> = {
<Row Item={Item} Index=0 />
}
let pinned = <Highlight Item=<Contact name="Zed" /> Row={Compact} />

The subset rule decides what happens at run time: the arguments reach the function by name, a parameter the function does not declare is dropped (Compact never sees Index), and a parameter it does declare is always present, because the type supplied it. A paren-style call on a function-typed value, Row(item, 0), is rejected with a diagnostic showing the element form: positions would depend on the order of parameters the value’s own declaration does not share.

Some functions are never called from NX. A tool a host offers to a model, or a function that builds the arguments of a request, is called by the host, with arguments the host checks against the function’s own parameters. No one parameter list fits every such function, so the property that holds one is declared at a function reference type, <function ... />: R, which takes a function of any parameters whose result satisfies R:

type HttpArguments = { url:string }
type FunctionTool = { name:string function: <function ... />: object* }
type HttpTool = { name:string arguments: <function ... />: HttpArguments }
let findPlans(region:string, limit?:int): string* = {region}
let planRequest(plan:string): HttpArguments = <HttpArguments url={plan} />
let tools = {
<FunctionTool name="find_plans" function={findPlans} />
<HttpTool name="get_plan" arguments={planRequest} />
}

<function ... />: object* takes any function at all; a stated result, as on arguments, is checked where the function is bound, so arguments={findPlans} is an error in the editor rather than a failure in the host. Each renders as the same Function record, which the host calls by the function’s own parameter names.

NX code can pass such a value on, store it and compare it, and cannot call it:

let invoke(f: <function ... />: int, n:int): int = <f n={n} />
// error: 'f' cannot be called because its parameters are not stated

To call a function, declare the binding at a function type with its parameters, f: <function n:int />: int.

let clamp(value:int, low:int = 0, high:int = 100): int = {
if value < low { low } else { if value > high { high } else { value } }
}
let a = { clamp(150) } // 100
let b = { clamp(-5, -10) } // -5
let c = <clamp value=150 high=120 /> // 120: an element call skips `low`

Use the paren style for small, general-purpose functions that a program calls in many places and that read naturally with parentheses: min, floor, rgb, a formatter. Use the element style for everything else, and always for markup. Neither is enforced; they are what the two styles are for.

  • A paren-style function is called by position, clamp(150, 0), or as an element, <clamp value=150 />, which binds arguments by name. Calling it with parentheses is the usual form.
  • Parameters a caller may omit — optional ones (title?:string) and defaulted ones (low:int = 0) — come after every required parameter. let f(a?:int, b:int) is rejected.
  • A positional call supplies every required parameter and may stop before any of the trailing ones. To skip one in the middle, call the function as an element and name the ones you pass.
  • A parameter a call leaves out takes its default, or {} when it is optional.

A default is evaluated by the function each time a call leaves its parameter out; it is never copied into the calling code. When a library changes a default, every existing caller gets the new one. A default sees the parameters declared before it and the declarations of the function’s own module, including private ones:

private let separator = " · "
export let join(first:string, second:string, sep:string = { separator }): string = {
first + sep + second
}

A caller in another module writes join("a", "b") without being able to name separator. A default cannot read a parameter declared after it, and it is checked against its parameter’s type. A function type takes no defaults, since whoever calls a function-typed value supplies every parameter the type declares.