Page actions

provideRender()

provideRender() registers the defaults that every <render-spec> in an application falls back to: a component registry, a state store, the named functions a spec may call through $computed, and the handlers its actions dispatch to. It returns EnvironmentProviders, so it belongs in ApplicationConfig.providers or in a bootstrapApplication() call. The running example is the computed-functions demo, whose four functions are registered exactly this way, and this page walks the files that make it work.

What the demo does

The Run tab shows a split view. On the left is a live render surface; on the right is the spec JSON that feeds it, arriving character by character. Nothing streams until you press play in the transport bar at the bottom, which also lets you scrub the timeline and change the speed.

Press play and the first spec streams in: hello world comes out uppercased and streaming comes out reversed. Switch to Data Display and an ISO timestamp comes out as a local date while 7 x 6 comes out as 42. None of those results are in the spec. The spec asks for a function by name, and the four functions registered in provideRender() produce the text. The Spec tabs in the header hold three specs -- Text Transforms, Data Display, and Mixed Functions -- and each tab restarts the stream with a different mix of the same four functions.

How it is built

Four pieces carry the feature: an application config that registers the functions, a registry and store held by the demo component, the <render-spec> element that mounts the surface, and a view component that receives the finished value. Open the Code tab to read them in place.

Registering the computed functions

The whole application config is one call. provideRender() receives a functions map, and every key in it becomes a name a spec may call.

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRender } from '@threadplane/render';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideRender({
      functions: {
        formatDate: (args: Record<string, unknown>) => new Date(args['value'] as string).toLocaleDateString(),
        uppercase: (args: Record<string, unknown>) => (args['value'] as string).toUpperCase(),
        multiply: (args: Record<string, unknown>) => (args['a'] as number) * (args['b'] as number),
        reverse: (args: Record<string, unknown>) => (args['value'] as string).split('').reverse().join(''),
      },
    }),
  ],
};

Each function takes a single args object and returns a value, which is the ComputedFunction contract from @json-render/core. The argument names are yours: formatDate and uppercase and reverse read args['value'], and multiply reads args['a'] and args['b'], so a spec that calls multiply has to supply those two keys.

Note: A computed function runs on every resolution pass

Prop resolution runs whenever the spec or the bound state changes, which during a stream is many times a second. Keep these functions pure and cheap, and memoize anything expensive outside the function body.

A spec calls one of them with a $computed expression in place of a literal prop value. The demo's first spec asks for two of the four:

const upper = {
  type: 'Value',
  props: {
    label: 'Uppercase',
    value: { $computed: 'uppercase', args: { value: 'hello world' } },
  },
};
 
const reversed = {
  type: 'Value',
  props: {
    label: 'Reversed',
    value: { $computed: 'reverse', args: { value: 'streaming' } },
  },
};

The args values are themselves prop expressions, so an argument may be a literal, as it is here, or a $state reference that reads from the store.

The registry and store the surface renders with

This example puts its functions in the global config and keeps the other two pieces local. The component builds a registry of three inline view components with defineAngularRegistry() and an empty store with signalStateStore().

computed-functions.component.ts -- registry and store
protected readonly registry = defineAngularRegistry({
  Value: DemoValueComponent,
  Heading: DemoHeadingComponent,
  Card: DemoCardComponent,
});
 
protected readonly store = signalStateStore({});

Both are equally valid in provideRender(). Keeping them on the component is what you do when one surface needs its own component set, which is the case here because the three view components exist only for this demo.

Mounting the surface

<render-spec> takes the spec and, because this example did not put them in the global config, the registry and store as inputs. The loading input tells the registered components that the spec is still arriving, which is how the skeleton rows appear ahead of the real values.

computed-functions.component.ts -- the render surface
<div primary>
  <div class="cap">Live Render Output</div>
  @if (simulator.spec(); as renderedSpec) {
    <render-spec [spec]="renderedSpec" [registry]="registry" [store]="store" [loading]="simulator.playing()" />
  } @else {
    <div class="placeholder">Press play to start streaming…</div>
  }
</div>

No functions input appears here, so the component falls back to the map registered in provideRender().

What a view component receives

A view component never sees a $computed expression. It declares plain inputs and receives resolved values.

computed-functions.component.ts -- the Value view
class DemoValueComponent {
  readonly label = input('');
  readonly value = input<unknown>('');
  // Coerce through toDisplayText so an unresolved binding object mid-stream
  // (e.g. a partial `$computed` value) renders as the skeleton, not `[object Object]`.
  readonly displayValue = computed(() => toDisplayText(this.value()));
  readonly childKeys = input<string[]>([]);
  readonly spec = input<Spec | null>(null);
  readonly bindings = input<Record<string, string>>({});
  readonly emit = input<(event: string) => void>(() => {});
  readonly loading = input(false);
}

label and value are the props the spec sets; childKeys, spec, bindings, emit, and loading are supplied by the render engine to every registered component. value is typed as unknown and coerced for display because mid-stream a $computed expression can still be half-parsed, and the demo prefers a skeleton row over rendering a partial object.

The graph that ships with the example

The example also carries a LangGraph graph. It plays no part in the render pipeline: nothing in the Angular application connects to it, and the spec that streams on the left comes from a local simulator, not from an agent. The graph is a single node that answers questions about computed functions, and it is included here because the Code tab shows it.

graph.py
"""
Render Computed Functions Graph
 
A LangGraph StateGraph that explains computed functions and prop resolution
for data transformation in render specs.
"""
 
from pathlib import Path
from langgraph.graph import StateGraph, MessagesState, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage
 
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
 
 
def build_computed_functions_graph():
    """
    Constructs a graph that explains computed functions.
 
    The agent responds with guidance on defining custom functions for
    prop resolution, expressions, and data transformation in render specs.
    """
    llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
 
    async def generate(state: MessagesState) -> dict:
        system_prompt = (PROMPTS_DIR / "computed-functions.md").read_text()
        messages = [SystemMessage(content=system_prompt)] + state["messages"]
        response = await llm.ainvoke(messages)
        return {"messages": [response]}
 
    graph = StateGraph(MessagesState)
    graph.add_node("generate", generate)
    graph.set_entry_point("generate")
    graph.add_edge("generate", END)
 
    return graph.compile()
 
 
graph = build_computed_functions_graph()

Import

import { provideRender, RENDER_CONFIG } from '@threadplane/render';

Signature

function provideRender(config: RenderConfig): EnvironmentProviders;

The returned providers carry the RENDER_CONFIG value and the internal lifecycle service that coordinates mount and unmount events across dynamically rendered components. Call provideRender() once per application.

RenderConfig

interface RenderConfig {
  telemetry?: boolean;
  registry?: AngularRegistry;
  store?: StateStore;
  functions?: Record<string, ComputedFunction>;
  handlers?: Record<string, (params: Record<string, unknown>) => unknown | Promise<unknown>>;
}
PropertyTypeDescription
telemetrybooleanSet false to disable automatic development browser collection for this render tree
registryAngularRegistryDefault component registry for all <render-spec> instances
storeStateStoreDefault state store for all <render-spec> instances
functionsRecord<string, ComputedFunction>Named functions a spec may call through $computed
handlersRecord<string, (params: Record<string, unknown>) => unknown | Promise<unknown>>Named action handlers invoked when interactive elements fire

Every property is optional. Provide only the defaults you need, as the example does with functions alone.

The RENDER_CONFIG token

The configuration object is stored under the RENDER_CONFIG injection token, which is exported alongside provideRender(). Inject it when you need to read the defaults yourself:

import { inject } from '@angular/core';
import { RENDER_CONFIG } from '@threadplane/render';
 
const config = inject(RENDER_CONFIG, { optional: true });
// null when provideRender() was not called

Resolution priority

RenderSpecComponent resolves each value independently, and an input always wins over the global default.

ValuePriority
registry[registry] input, then RENDER_CONFIG, then the VIEW_REGISTRY token from provideViews(), then an empty registry
store[store] input, then RENDER_CONFIG, then an internal signalStateStore() seeded from spec.state
functions[functions] input, then RENDER_CONFIG
handlers[handlers] input, then RENDER_CONFIG

Because the fallbacks are per value, a surface may take one piece from the global config and override another. The example does exactly that in reverse: it registers functions globally and passes registry and store as inputs.

// Global: one registry and one handler map for the whole application
provideRender({
  registry: baseRegistry,
  handlers: { log: (params: Record<string, unknown>) => console.log(params) },
});
<!-- This surface keeps the global registry and handlers, but binds its own store -->
<render-spec [spec]="spec" [store]="localStore" />

Calling provideRender is optional

provideRender() is not required. Skip it and <render-spec> still renders, as long as the registry reaches it another way -- an input, or the VIEW_REGISTRY token -- and the internal store fallback covers state.

<!-- Works without provideRender() -- every value arrives as an input -->
<render-spec [spec]="spec" [registry]="registry" [store]="store" />

What's Next

provideRenderfunction

Bootstrap `@threadplane/render` in an Angular application or standalone component tree. Registers the shared RenderConfig token and the internal `RenderLifecycleService` that coordinates mount/unmount events across dynamically rendered components. Call this once in `bootstrapApplication` (or the root `ApplicationConfig`).

provideRender(config: RenderConfig): EnvironmentProviders

Parameters

ParameterTypeDescription
configRenderConfigOptions bag that controls the render feature set: - `registry` — component registry returned by defineAngularRegistry; maps tool-call names to Angular components. - `store` — optional `StateStore` for `\@json-render/core` state binding. - `functions` — optional map of computed functions available inside specs. - `handlers` — optional map of event handlers triggered by spec actions.

Returns

EnvironmentProviders

Examples

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { defineAngularRegistry, provideRender } from '@threadplane/render';
import { DayCardComponent } from './day-card.component';

const registry = defineAngularRegistry({ day_card: DayCardComponent });

bootstrapApplication(AppComponent, {
  providers: [
    provideRender({ registry }),
  ],
});

Looking for something specific?