---
status: draft
last_updated: 2026-05-18
---
# Element Factory
The element factory (`h()`, `createRoot()`, `createComponent()`, `Fragment`) and JSX runtime exports that construct the universal tree.
## Overview
UJSX trees are built by factory functions, not by class instantiation or builder patterns. The tree is a plain-data structure — objects with `type`, `props`, and `children` — and the factories ensure that structure is consistent, normalized, and free of null/false sentinel values.
The factory layer is thin by design. It does not validate props against schemas, does not call any host, and does not maintain any tree-level state beyond a root ID counter. Its job is to produce `UNode` values that are safe for any host to consume.
## h() Factory
`h()` is the universal element constructor. It mirrors the hyperscript signature (`type, props, ...children`) and produces either a `UElement` or a `URoot` depending on the `type` argument.
```typescript
function h(type: UType, props?: UniversalProps | null, ...children: UNode[]): UElement | URoot
```
### Branch on type
- **`type === "root"`** — returns a `URoot` with `type: "root"`. This is the only way the `"root"` literal enters the tree. No other type string produces a `URoot`.
- **Any other string or `ComponentFn`** — returns a `UElement` with that type. Component functions are rendered by hosts; `h()` does not call them.
### Child normalization
Children are processed in two steps:
1. **Flatten** — `children.flat(Infinity)` recursively flattens nested arrays. This means `[Fragment({ children: [...] }), [...nested]]` all collapse into a single flat list. The cast to `1` (instead of `Infinity`) is a TypeScript type-level concession; at runtime, `Infinity` flattens fully.
2. **Filter** — `c != null && c !== false` removes `null`, `undefined`, and `false` values from the child list. This matches JSX conventions: `{condition &&
( name: string, render: (props: P) => UNode, targets?: string[], ): UComponent
``` `createComponent()` wraps a render function and attaches metadata. It does not create an element — it returns a `UComponent`, which is a callable function with `displayName` and `targets` properties. ### displayName `displayName` is for debugging and error messages. It is analogous to React's `displayName` on function components. When a host logs or inspects a component, `displayName` provides a human-readable label instead of `"anonymous"`. ### targets `targets` is a string array hinting which hosts should render this component. For example, `["dom", "ssr"]` means the component is relevant to DOM and SSR hosts. A host that does not appear in `targets` can skip the component. This is a **hint, not a gate**. A host is free to render any component regardless of `targets`. The field exists for host-level optimization and filtering, not for access control. ### Type coercion The implementation casts `render` to `UComponent
` via `unknown`. This is safe because `UComponent
` extends the render function's signature with optional properties (`displayName`, `targets`), and those properties are assigned immediately after the cast. The cast exists because TypeScript cannot infer that adding properties to a function makes it conform to the interface. ## Fragment ```typescript function Fragment(props: { children?: UNode[] }): UNode[] ``` `Fragment` returns a flat array of `UNode` values. It does **not** wrap children in a containing element. This is the defining difference from `h("fragment", {}, ...children)`, which would produce a `UElement` node in the tree. Fragment is not a tree node — it is a grouping mechanism that dissolves during construction. Its return type is `UNode[]`, not `UNode`, reflecting that it expands into the parent's child list rather than occupying its own position. Child normalization is the same as `h()`: flatten and filter. ## JSX Runtime ```typescript export const jsx = h; export const jsxs = h; export const jsxDEV = h; ``` These three exports enable UJSX to serve as a JSX runtime. When a consumer configures their TypeScript/babel/astro JSX transform with: ```json { "jsxImportSource": "@alkdev/ujsx" } ``` The transform emits calls to `jsx`, `jsxs`, and `jsxDEV` from `@alkdev/ujsx/jsx-runtime` (or the appropriate entry point). All three are aliases for `h()` because UJSX does not distinguish between static children (`jsxs`), dynamic children (`jsx`), and dev-mode calls (`jsxDEV`) at the factory level. The distinction exists in React for dev warnings and children array vs. single-child optimizations; UJSX normalizes children uniformly, so the aliases are identical. Consumers who prefer hyperscript-style code can call `h()` directly. The JSX runtime exports exist solely for toolchain compatibility. ## Known Gaps ### `key` prop not extracted `h()` currently passes **all** props through to the element, including `key` if provided. The reconciler requires `key` as a first-class field on `UElement` for identity-based children matching (see [reconciler.md](reconciler.md) and [ADR-004](decisions/004-key-as-first-class-field.md)). When `key` extraction is implemented, `h()` should: 1. Remove `key` from `resolvedProps` before constructing the element. 2. Promote `key` to `element.key` as a top-level field. 3. Ensure component functions never receive `key` in their props. This is documented in the schema architecture ([schema.md](schema.md) — Known Gaps: `key` field on `UElement`) and the reconciler key field ADR ([decisions/004-key-as-first-class-field.md](decisions/004-key-as-first-class-field.md)). ### No prop validation `h()` does not validate props against the TypeBox schema. It shallow-copies whatever is passed. Runtime validation is the consumer's or host's responsibility. This keeps the factory fast and dependency-free — `h()` never imports from `schema.ts` at runtime. ## Constraints - **`h()` is pure** — no side effects beyond the `_idCounter` increment in `createRoot()`. It does not call hosts, subscribe to signals, or mutate external state. - **Children are always flat** — `flat(Infinity)` + filter means consumers never receive nested arrays or null/false children from factory output. Hosts and transforms can assume `element.children` is a flat `UNode[]` with no null slots. - **Props are not deep-cloned** — `h()` spreads props shallowly. Nested objects are shared references. Consumers must not mutate element.props and expect isolation. - **`Fragment` produces arrays, not elements** — hosts and transforms must handle `UNode[]` return values from component renders. A Fragment does not appear in the tree. - **`_idCounter` is module-scoped** — each module instance has its own counter. If multiple copies of UJSX are loaded (e.g., different package versions), roots from different copies may collide on `id` values. - **JSX aliases are identical** — `jsx`, `jsxs`, and `jsxDEV` are the same function. UJSX does not differentiate between them. Dev-mode only features (e.g., source location) are not currently supported. ## References - Source: `src/core/h.ts` - Schema types: `src/core/schema.ts` - Schema architecture: `docs/architecture/schema.md` - Key field ADR: [decisions/004-key-as-first-class-field.md](decisions/004-key-as-first-class-field.md) - Reconciler architecture: [reconciler.md](reconciler.md) - Key-based reconciliation research: `docs/research/reconciler/02-key-based-children-reconciliation.md`