Registry

A registry maps your catalog definitions to platform-specific implementations. The catalog defines what AI can generate — the registry provides the how.

What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.

  • @json-render/react — Components (React elements) and action handlers
  • @json-render/react-native — Components (React Native elements) and action handlers
  • @json-render/remotion — Clip components, transitions, and effects

@json-render/react

defineRegistry

Use defineRegistry to create a type-safe registry from your catalog. Pass your components, actions, or both:

import { defineRegistry } from '@json-render/react';
import { myCatalog } from './catalog';

export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
  components: {
    Card: ({ props, children }) => (
      <div className="card">
        <h2>{props.title}</h2>
        {props.description && <p>{props.description}</p>}
        {children}
      </div>
    ),

    Button: ({ props, emit }) => (
      <button onClick={() => emit("press")}>
        {props.label}
      </button>
    ),
  },

  actions: {
    submit_form: async (params, setState) => {
      const res = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(params),
      });
      const result = await res.json();
      setState((prev) => ({ ...prev, formResult: result }));
    },

    export_data: async (params) => {
      const blob = await generateExport(params.format);
      downloadBlob(blob, `export.${params.format}`);
    },
  },
});

The returned object contains:

  • registry — component registry for <Renderer />
  • handlers — factory for ActionProvider-compatible handlers
  • executeAction — imperative action dispatch (for use outside the React tree)

Component Props

Each component receives a ComponentContext object:

interface ComponentContext {
  props: T;                                // Type-safe props from your catalog
  children?: React.ReactNode;              // Rendered children (for slot components)
  emit: (event: string) => void;           // Emit a named event (e.g. "press")
  loading?: boolean;                       // Whether the renderer is in a loading state
  bindings?: Record<string, string>;       // State paths from $bindState/$bindItem expressions
}

Props are automatically inferred from your catalog, so props.title is typed as string if your catalog defines it that way.

Using bindings for two-way binding

When a spec uses { "$bindState": "/path" } or { "$bindItem": "field" } on a prop, the renderer resolves the value into props and provides the write-back path in bindings. Use the useBoundProp hook to wire both together:

import { useBoundProp, defineRegistry } from '@json-render/react';

// Inside your registry:
TextInput: ({ props, bindings }) => {
  const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
  return (
    <input
      value={value ?? ""}
      onChange={(e) => setValue(e.target.value)}
    />
  );
},

useBoundProp returns [resolvedValue, setter]. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op.

Action Handlers

Instead of AI generating arbitrary code, it declares intent by name. Your application provides the implementation. This is a core guardrail.

Actions are declared in your catalog. The @json-render/react schema supports an actions key where you define what operations AI can trigger:

import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';

const catalog = defineCatalog(schema, {
  components: { /* ... */ },
  actions: {
    submit_form: {
      params: z.object({
        formId: z.string(),
      }),
      description: 'Submit a form',
    },
    export_data: {
      params: z.object({
        format: z.enum(['csv', 'pdf', 'json']),
      }),
    },
    navigate: {
      params: z.object({
        url: z.string(),
      }),
    },
  },
});

Action handlers receive (params, setState, state) and are defined inside defineRegistry:

export const { handlers, executeAction } = defineRegistry(catalog, {
  actions: {
    submit_form: async (params, setState) => {
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify({ formId: params.formId }),
      });
      const result = await response.json();
      setState((prev) => ({ ...prev, formResult: result }));
    },

    export_data: async (params) => {
      const blob = await generateExport(params.format);
      downloadBlob(blob, `export.${params.format}`);
    },

    navigate: (params) => {
      window.location.href = params.url;
    },
  },
});

Data Binding

Most data binding is handled automatically by the renderer — $state, $item, and $index expressions in props are resolved before your component receives them. See the Data Binding guide for the full reference.

For two-way binding (form inputs), use { "$bindState": "/path" } on the natural value prop (or { "$bindItem": "field" } inside repeat scopes). The renderer provides a bindings map with the state path for each bound prop. Use useBoundProp to get [value, setValue]:

import { useBoundProp } from '@json-render/react';

// Inside defineRegistry components:

Input: ({ props, bindings }) => {
  const [value, setValue] = useBoundProp<string>(
    props.value,
    bindings?.value
  );
  return (
    <input
      value={value ?? ''}
      onChange={(e) => setValue(e.target.value)}
      placeholder={props.placeholder}
    />
  );
},

For read-only state access (e.g. displaying a value from state), use $state expressions in props — they are resolved before the component receives them. For custom logic, use useStateStore and getByPath from @json-render/core.

Using the Renderer

Wire everything together with providers and the <Renderer /> component:

import { useMemo, useRef } from 'react';
import {
  Renderer,
  StateProvider,
  VisibilityProvider,
  ActionProvider,
} from '@json-render/react';
import { registry, handlers } from './registry';

function App({ spec, state, setState }) {
  const stateRef = useRef(state);
  const setStateRef = useRef(setState);
  stateRef.current = state;
  setStateRef.current = setState;

  const actionHandlers = useMemo(
    () => handlers(() => setStateRef.current, () => stateRef.current),
    [],
  );

  return (
    <StateProvider initialState={state}>
      <VisibilityProvider>
        <ActionProvider handlers={actionHandlers}>
          <Renderer spec={spec} registry={registry} />
        </ActionProvider>
      </VisibilityProvider>
    </StateProvider>
  );
}

@json-render/react-native

@json-render/react-native uses the same defineRegistry API. The only difference is that components return React Native elements instead of HTML:

import { defineRegistry } from '@json-render/react-native';
import { View, Text, Pressable } from 'react-native';

export const { registry } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) => (
      <View style={styles.card}>
        <Text style={styles.title}>{props.title}</Text>
        {children}
      </View>
    ),

    Button: ({ props, emit }) => (
      <Pressable onPress={() => emit("press")}>
        <Text>{props.label}</Text>
      </Pressable>
    ),
  },
});

See the @json-render/react-native API reference for the full API.

@json-render/remotion

@json-render/remotion takes a different approach. Instead of defineRegistry, it uses a plain component registry with built-in standard components for video production:

import { Renderer, standardComponents } from '@json-render/remotion';

// Use the standard components directly
<Renderer spec={timelineSpec} components={standardComponents} />

// Or extend with your own
const components = {
  ...standardComponents,
  CustomSlide: ({ clip }) => <AbsoluteFill>{/* ... */}</AbsoluteFill>,
};

The Remotion schema also supports transitions and effects in the catalog rather than actions.

See the @json-render/remotion API reference for the full API.

Next

Learn about data binding for dynamic values.