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.
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.
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().
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.
<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.
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.
"""
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>>;
}| Property | Type | Description |
|---|---|---|
telemetry | boolean | Set false to disable automatic development browser collection for this render tree |
registry | AngularRegistry | Default component registry for all <render-spec> instances |
store | StateStore | Default state store for all <render-spec> instances |
functions | Record<string, ComputedFunction> | Named functions a spec may call through $computed |
handlers | Record<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 calledResolution priority
RenderSpecComponent resolves each value independently, and an input always wins over the global default.
| Value | Priority |
|---|---|
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
The inputs and outputs of the element that mounts a spec.
Build the registry you pass to the config or to an input.
Create the signal-backed store that $bindState paths read and write.
The spec shape that $computed expressions live in.