Externals

Externals are values your host app provides to the DSL: state, functions, and components. Inside the DSL they are referenced with a $ prefix. This is how a page reaches the outside world — the logged-in user, a formatDate helper, an icon component from your design system.

Register them when you create the runtime:

import { Nu } from '@nuforge/core';
import * as t from '@nuforge/types';

const nu = Nu.create({
  externals: {
    states: [
      t.externalState({ name: 'user', init: { name: 'Ada', plan: 'Pro' } }),
    ],
    functions: (nu) => [
      t.externalFunc({ name: 'shout', func: (s) => String(s).toUpperCase() }),
    ],
    components: [
      t.externalComponent({
        name: 'Badge',
        render: Badge, // a React component
        props: [t.componentProp({ name: 'label' })],
      }),
    ],
  },
});

Using externals in the DSL

KindDeclared asUsed in the DSL
Statet.externalState({ name, init })$user, $user.name
Functiont.externalFunc({ name, func })$shout($user.name)
Componentt.externalComponent({ name, render, props })<$Badge label={$user.plan} />
component App() {} => (
  <div>
    <h2>{$shout($user.name)}</h2>
    <p>{"Current plan:"} <$Badge label={$user.plan} /></p>
  </div>
)

External component tags use the $ prefix and resolve to the React component you passed as render. Only the props you declare in props are forwarded.

State is read-only to the DSL

External state is deep-frozen, so expressions can only read it — they can never mutate host state. Attempting to assign to an external (e.g. $user.name = "x") is rejected by the evaluator.

Only the host can update external state, and doing so re-renders every view that reads it:

nu.externals.updateStateValue('user', { name: 'Grace', plan: 'Team' });

This is the bridge for pushing live host data (auth, a store, a query result) into a running page: keep an external state in sync with your app state and the page reacts automatically.

External functions

External functions are plain host functions. They receive the evaluated arguments and their return value flows back into the expression:

t.externalFunc({ name: 'formatPrice', func: (n) => `$${Number(n).toFixed(2)}` });
// DSL:  <span>{$formatPrice(item.price)}</span>

The factory form functions: (nu) => [...] gives you the Nu instance if a function needs to read or change the document.

External components

External components let the DSL render real React components from your codebase — design-system primitives, charts, anything. The frame passes the declared props (and children) to your component:

function Badge({ label }: { label?: string }) {
  return <span className="badge">{label}</span>;
}
// registered with props: [t.componentProp({ name: 'label' })]
// DSL:  <$Badge label={$user.plan} />

See it live (with host controls that update $user) on the Examples page.

Hook-based external components

An external component's render is a plain React function component — it can use any hook internally, exactly as it would anywhere else in a React tree. There's no special adapter to work around; a scroll-linked reveal effect built on motion's useScroll/useTransform registers as an external the same way Badge does above:

import { motion, useScroll, useTransform } from 'motion/react';
import { useRef } from 'react';

function FadeInOnScroll({ children }: { children?: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({ target: ref });
  const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]);
  return <motion.div ref={ref} style={{ opacity }}>{children}</motion.div>;
}
// registered with props: [] — content comes through as children
// DSL:  <$FadeInOnScroll><h2>{"Revealed on scroll"}</h2></$FadeInOnScroll>

Caveat — editor canvases that render into an <iframe>. @nuforge/editor's IframeCanvas portals the frame into an isolated <iframe> for CSS isolation. A React portal only relocates DOM nodes into that iframe — the JavaScript building them still runs in the host page's realm. Any hook that reads a bare window/document global (scroll position, matchMedia, ResizeObserver/IntersectionObserver defaults) will silently observe the host page instead of the iframe the user actually sees and scrolls. Resolve the canvas's real window/document with useCanvasWindow() (from @nuforge/editor/@nuforge/editor/react) instead of the ambient global — it returns the iframe's own { doc, window } inside IframeCanvas, and just falls back to the ambient globals anywhere else (e.g. a direct, non-iframe <NuFrame/> render):

import { useCanvasWindow } from '@nuforge/editor/react';
import { useEffect, useRef } from 'react';

const { doc } = useCanvasWindow();
const container = useRef<HTMLElement | null>(null);
useEffect(() => {
  container.current = doc?.scrollingElement as HTMLElement | null;
}, [doc]);

const { scrollYProgress } = useScroll({ target: ref, container });

Next steps