Render · Guides

Component Registry

The component registry maps element type names from your spec to Angular component classes. It's the bridge between the declarative JSON spec and your Angular component tree.

#Creating a Registry

Let's create a registry with defineAngularRegistry() from a plain object that maps type names to component classes:

import { defineAngularRegistry } from '@threadplane/render';
import { TextComponent } from './text.component';
import { CardComponent } from './card.component';
import { ButtonComponent } from './button.component';
import { ContainerComponent } from './container.component';
 
export const uiRegistry = defineAngularRegistry({
  Text: TextComponent,
  Card: CardComponent,
  Button: ButtonComponent,
  Container: ContainerComponent,
});

The returned AngularRegistry object has two methods:

  • getEntry(name: string) -- returns the fully-normalized entry ({ component, fallback, schema?, description? }) for a registered name, or undefined if not registered. The resolved fallback is the entry's own renderer, or the library's default when the entry omits one.
  • names() -- returns an array of all registered type names
uiRegistry.getEntry('Text')?.component; // TextComponent
uiRegistry.getEntry('Unknown');         // undefined
uiRegistry.getEntry('Text')?.fallback;  // fallback renderer (or default)
uiRegistry.names();                     // ['Text', 'Card', 'Button', 'Container']

#Fallback Rendering

Fallbacks belong to registered entries. When an element's type is not registered, there is no entry to read and the element renders nothing. When the type is registered, the entry's configured fallback can fill a transient gap while state-bound props are still resolving. Once the real component mounts, it stays mounted -- later re-renders never revert to the fallback.

#The Component Input Contract

Every component rendered by @threadplane/render receives inputs conforming to the AngularComponentInputs interface. Your custom props from the spec are spread as additional inputs alongside the standard ones.

#Standard Inputs

InputTypeDescription
emit(event: string) => voidFunction to dispatch named events
bindingsRecord<string, string>Two-way binding paths: prop name to absolute state path
loadingbooleanWhether the spec is currently streaming
childKeysstring[]Element keys for recursive child rendering
specSpecThe full spec object (for child resolution)

#Custom Props

Any props defined in the element's props are resolved and passed as additional inputs. For example, given this element:

{
  "type": "Text",
  "props": {
    "label": "Hello",
    "size": "large"
  }
}

Your component receives label and size as inputs alongside the standard inputs.

#Writing a Renderable Component

Here's a complete component designed to work with the rendering system:

import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import type { Spec } from '@json-render/core';
 
@Component({
  selector: 'app-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <h2>{{ title() }}</h2>
      <p>{{ description() }}</p>
      @if (loading()) {
        <div class="loading-indicator">Loading...</div>
      }
    </div>
  `,
})
export class CardComponent {
  // Custom props from the spec
  readonly title = input<string>('');
  readonly description = input<string>('');
 
  // Standard inputs from AngularComponentInputs
  readonly emit = input<(event: string) => void>(() => {});
  readonly bindings = input<Record<string, string>>({});
  readonly loading = input<boolean>(false);
  readonly childKeys = input<string[]>([]);
  readonly spec = input<Spec | null>(null);
}
Input defaults

Always provide default values for your inputs. The rendering system spreads resolved props onto the component, but not all standard inputs are guaranteed to have values in every context.

#Two-Way Bindings

When a prop uses $bindState, the bindings input receives a mapping from the prop name to the state path. This enables two-way binding patterns:

// In your spec
{
  type: 'Input',
  props: {
    value: { $bindState: '/form/email' },
    label: 'Email',
  },
}

Your component receives:

  • value resolved to the current state value (e.g., "test@example.com")
  • bindings set to { value: '/form/email' }

You can use the bindings map to write back to the store:

import { Component, ChangeDetectionStrategy, input, inject } from '@angular/core';
import { RENDER_CONTEXT } from '@threadplane/render';
 
@Component({
  selector: 'app-input',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <label>{{ label() }}</label>
    <input [value]="value()" (input)="onInput($event)" />
  `,
})
export class InputComponent {
  readonly value = input<string>('');
  readonly label = input<string>('');
  readonly bindings = input<Record<string, string>>({});
  readonly childKeys = input<string[]>([]);
  readonly spec = input<unknown>(null);
 
  private readonly ctx = inject(RENDER_CONTEXT);
 
  onInput(event: Event) {
    const path = this.bindings()['value'];
    if (path) {
      const target = event.target as HTMLInputElement;
      this.ctx.store.set(path, target.value);
    }
  }
}

#RenderHost

Inside a mounted view component, injectRenderHost() exposes the element-scoped host:

import { Component, inject } from '@angular/core';
import { injectRenderHost } from '@threadplane/render';
 
@Component({
  selector: 'app-approval',
  standalone: true,
  template: `
    <button (click)="approve()">Approve</button>
  `,
})
export class ApprovalComponent {
  private readonly host = injectRenderHost();
 
  approve() {
    this.host.set('/approved', true);
    this.host.emit('approved');
    this.host.result({ approved: true });
  }
}

Use set(path, value) for state writes, emit(event, payload?) for spec on handlers, and result(value) when the component has produced a value that the host should observe as a RenderResultEvent.

#Recursive Children

Container components can render their children by using the childKeys and spec inputs with RenderElementComponent:

import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { RenderElementComponent } from '@threadplane/render';
import type { Spec } from '@json-render/core';
 
@Component({
  selector: 'app-container',
  standalone: true,
  imports: [RenderElementComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="container">
      @for (key of childKeys(); track key) {
        <render-element [elementKey]="key" [spec]="spec()!" />
      }
    </div>
  `,
})
export class ContainerComponent {
  readonly childKeys = input<string[]>([]);
  readonly spec = input<Spec | null>(null);
  readonly loading = input<boolean>(false);
  readonly emit = input<(event: string) => void>(() => {});
  readonly bindings = input<Record<string, string>>({});
}

This enables deeply nested component trees -- containers render their children, which can themselves be containers with more children.

#Providing the Registry

Let's provide the registry. You have two ways to do it:

// app.config.ts
import { provideRender, defineAngularRegistry } from '@threadplane/render';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideRender({
      registry: defineAngularRegistry({
        Text: TextComponent,
        Card: CardComponent,
      }),
    }),
  ],
};
<!-- Registry is resolved from RENDER_CONFIG automatically -->
<render-spec [spec]="spec" />

When both are provided, the input always takes precedence over the global config.

#Next Steps