Skip to main content
This page provides implementation guidance for rendering dynamic forms in your application.

Getting started

To render a dynamic form, you need:
  1. A schema — the JSON Schema defining data types and validation
  2. A uiSchema — the UI Schema defining layout and controls
  3. A data — (optional) existing data to prefill the form
  4. A renderer — either a JSON Forms library or your own implementation

Using JSON Forms libraries

The fastest way to get started is using an official JSON Forms renderer: These libraries handle schema interpretation, validation, and rendering out of the box.

Schema-level hints

The JSON Schema includes a format keyword that indicates how a field should be rendered. Use it to select the appropriate input component:
FormatImplementation
format: "date"Render a date picker input

Custom renderers for Enterprise API Suite

The UI Schema extends JSON Forms with custom options that require additional rendering logic:
OptionImplementation
data.sourcePopulate options from any data source (API, database, etc.) based on the source identifier. The Enterprise API Suite provides endpoints for available sources.
data.excludeFilter the data source based on the specified restriction
format: "postal-code"Render a postal code input with country-aware formatting
rulesApply client-side validation rules (e.g., age thresholds)
dependsOnRe-fetch or update the control when a dependency value changes

Building a custom renderer

If you need full control over the user experience, you can build your own form renderer. Your implementation must:
  1. Parse the schema — Extract property definitions, types, and validation rules
  2. Parse the uiSchema — Build the layout tree from elements
  3. Render controls — Map schema types to appropriate input components
  4. Apply rules — Evaluate conditions and show/hide/enable/disable elements
  5. Validate input — Enforce schema constraints before submission
  6. Handle progressive disclosure — Re-render when the API returns updated schemas

Handling progressive disclosure

When implementing progressive disclosure:
  1. Render the form from schema, uiSchema, and any existing data
  2. Collect user input for the current step (Category)
  3. Submit the answers to the API
  4. Compare the new response with the previous one:
    • If there’s a new root property in the schema, or a new Category in the uiSchema, re-render the form to show the new questions
    • If the schema and uiSchema are unchanged, the form is complete

Examples: React custom renderers

The examples below show how to implement custom renderers using JSON Forms for React and Material UI. Each renderer follows the same pattern: a tester that matches specific UI Schema options, and a control component that renders the appropriate input.

Country renderer (data.source: "countries")

Fetches the country list and applies data.exclude filters. In this example, the data is fetched from List countries endpoint.
import { withJsonFormsControlProps } from '@jsonforms/react';
import { rankWith } from '@jsonforms/core';
import { Autocomplete, TextField } from '@mui/material';
import { useEffect, useState } from 'react';

// Custom renderer that fetches data for the data.source option
const DataSourceControl = (props) => {
  const { data, path, handleChange, uischema, label } = props;
  const [options, setOptions] = useState([]);
  const { data: dataOptions } = uischema.options || {};

  useEffect(() => {
    if (dataOptions?.source === 'countries') {
      // Fetch countries and apply exclude filters
      fetch('https://api.enterprise.uphold.com/core/countries', {
        headers: {
          'Authorization': `Bearer ${accessToken}` // Your OAuth2 access token
        }
      })
        .then((res) => res.json())
        .then((countries) => {
          // Apply exclude filter if specified
          const filtered = dataOptions.exclude?.restrictions
            ? countries.filter((country) =>
                !dataOptions.exclude.restrictions.some((r) =>
                  country.restrictions?.some((cr) => cr.scope === r.scope)
                )
              )
            : countries;
          setOptions(filtered.map((c) => ({ value: c.code, label: c.name })));
        });
    }
  }, [dataOptions]);

  return (
    <Autocomplete
      options={options}
      value={options.find((o) => o.value === data) || null}
      onChange={(_, selected) => handleChange(path, selected?.value)}
      getOptionLabel={(option) => option.label}
      renderInput={(params) => <TextField {...params} label={label} />}
    />
  );
};

// Tester: use this renderer when data.source is present
const dataSourceTester = rankWith(10, (uischema) =>
  uischema.options?.data?.source ? true : false
);

// Export for use with JsonForms
export const dataSourceRenderer = {
  tester: dataSourceTester,
  renderer: withJsonFormsControlProps(DataSourceControl),
};
This example demonstrates:
  • Detecting the data.source option with a custom tester
  • Fetching options from the API and applying data.exclude filters
  • Using Material UI’s Autocomplete for consistent styling
  • Passing existing data to prefill the form

Subdivision renderer (data.source: "subdivisions" + dependsOn)

Fetches subdivisions based on the selected country. Uses dependsOn to read the current country value and re-fetch when it changes. In this example, the data is fetched from Get country endpoint.
import { withJsonFormsControlProps } from '@jsonforms/react';
import { rankWith, Resolve, toDataPath } from '@jsonforms/core';
import { useJsonForms } from '@jsonforms/react';
import { Autocomplete, TextField } from '@mui/material';
import { useEffect, useRef, useState } from 'react';

// Custom renderer that fetches subdivisions based on the selected country
const SubdivisionControl = (props) => {
  const { data, path, handleChange, uischema, label } = props;
  const { core } = useJsonForms();
  const [options, setOptions] = useState([]);
  const isFirstRender = useRef(true);
  const { data: dataOptions } = uischema.options || {};

  // Read the country value from the dependency declared in dependsOn
  const dependsOn = uischema.options?.dependsOn;
  const countryScope = dependsOn?.find(({ name }) => name === 'country')?.scope;
  const country = countryScope ? Resolve.data(core?.data, toDataPath(countryScope)) : undefined;

  useEffect(() => {
    if (dataOptions?.source === 'subdivisions' && country) {
      // Clear the subdivision value when the country changes (except on initial render)
      if (!isFirstRender.current) {
        handleChange(path, undefined);
      }
      isFirstRender.current = false;

      // Fetch subdivisions from GET /countries/{country}
      fetch(`https://api.enterprise.uphold.com/core/countries/${country}`, {
        headers: {
          'Authorization': `Bearer ${accessToken}` // Your OAuth2 access token
        }
      })
        .then((res) => res.json())
        .then((data) => {
          const subdivisions = data.country?.subdivisions ?? [];
          setOptions(subdivisions.map((s) => ({ value: s.code, label: s.name })));
        });
    } else {
      setOptions([]);
    }
  }, [dataOptions, country]);

  return (
    <Autocomplete
      options={options}
      value={options.find((o) => o.value === data) || null}
      onChange={(_, selected) => handleChange(path, selected?.value)}
      getOptionLabel={(option) => option.label}
      disabled={!country}
      renderInput={(params) => <TextField {...params} label={label} />}
    />
  );
};

// Tester: use this renderer when data.source is "subdivisions"
const subdivisionTester = rankWith(10, (uischema) =>
  uischema.options?.data?.source === 'subdivisions'
);

// Export for use with JsonForms
export const subdivisionRenderer = {
  tester: subdivisionTester,
  renderer: withJsonFormsControlProps(SubdivisionControl),
};
This example demonstrates:
  • Reading dependency values from dependsOn using Resolve.data and toDataPath
  • Re-fetching subdivisions from Get country when the country changes
  • Clearing the selected value when the dependency changes (except on initial render)
  • Disabling the control until a country is selected

Date renderer (format: "date" + rules)

Renders a date picker and converts rules (e.g., difference-greater-than-or-equal-to-threshold, difference-less-than-or-equal-to-threshold) into min/max date constraints. The format: "date" is defined in the JSON Schema; this renderer adds support for the rules option from the UI Schema.
import { withJsonFormsControlProps } from '@jsonforms/react';
import { rankWith } from '@jsonforms/core';
import { TextField } from '@mui/material';

// Custom renderer that handles the format: "date" schema with validation rules
const DateControl = (props) => {
  const { data, path, handleChange, uischema, label } = props;

  const rules = uischema.options?.rules ?? [];

  // Your own function that converts rules into date picker constraints.
  // This must be implemented by the partner (e.g., evaluating threshold rules against the current date).
  const { min, max } = RulesEvaluator.datepicker.evaluate(rules);

  return (
    <TextField
      fullWidth
      margin="normal"
      label={label}
      type="date"
      value={data ?? ''}
      slotProps={{
        inputLabel: { shrink: true },
        htmlInput: { min, max },
      }}
      onChange={(e) => handleChange(path, e.target.value || undefined)}
    />
  );
};

// Tester: use this renderer when schema.format is "date"
const dateTester = rankWith(10, (schema) =>
  schema?.format === 'date'
);

// Export for use with JsonForms
export const dateRenderer = {
  tester: dateTester,
  renderer: withJsonFormsControlProps(DateControl),
};
This example demonstrates:
  • Detecting format: "date" in the JSON Schema with a custom tester
  • Delegating rule evaluation to a partner-implemented function
  • Applying min/max constraints to the native date input

Postal code renderer (format: "postal-code" + dependsOn)

Renders a text input for postal codes. Uses dependsOn to read the current country and subdivision values, then applies country-specific validation patterns.
import { withJsonFormsControlProps } from '@jsonforms/react';
import { rankWith, Resolve, toDataPath } from '@jsonforms/core';
import { useJsonForms } from '@jsonforms/react';
import { Autocomplete, TextField } from '@mui/material';
import { useEffect, useState } from 'react';

// Custom renderer that validates postal codes based on the selected country
const PostalCodeControl = (props) => {
  const { data, path, handleChange, uischema, label } = props;
  const { core } = useJsonForms();
  const [pattern, setPattern] = useState(null);

  // Read dependency values declared in dependsOn
  const dependsOn = uischema.options?.dependsOn;
  const countryScope = dependsOn?.find(({ name }) => name === 'country')?.scope;
  const subdivisionScope = dependsOn?.find(({ name }) => name === 'subdivision')?.scope;

  const country = countryScope ? Resolve.data(core?.data, toDataPath(countryScope)) : undefined;
  const subdivision = subdivisionScope ? Resolve.data(core?.data, toDataPath(subdivisionScope)) : undefined;

  useEffect(() => {
    if (uischema.options?.format === 'postal-code' && country) {
      // Your own service/function that returns a regex pattern for the given country.
      // This must be implemented by the partner (e.g., from a local mapping or an API).
      PostalCodeService.getPattern(country, subdivision).then(setPattern);
    } else {
      setPattern(null);
    }
  }, [uischema.options?.format, country, subdivision]);

  const validationError = data && pattern && !new RegExp(pattern).test(data) ? 'Invalid postal code format' : undefined;

  return (
    <TextField
      fullWidth
      margin="normal"
      label={label}
      value={data ?? ''}
      disabled={!country}
      error={!!validationError}
      helperText={validationError}
      onChange={(e) => handleChange(path, e.target.value)}
    />
  );
};

// Tester: use this renderer when options.format is "postal-code"
const postalCodeTester = rankWith(10, (uischema) =>
  uischema.options?.format === 'postal-code'
);

// Export for use with JsonForms
export const postalCodeRenderer = {
  tester: postalCodeTester,
  renderer: withJsonFormsControlProps(PostalCodeControl),
};
This example demonstrates:
  • Reading multiple dependency values (country, subdivision) from dependsOn
  • Delegating pattern resolution to a partner-implemented service
  • Applying regex validation against the resolved pattern
  • Disabling the control until a country is selected

Registering all custom renderers

Register all custom renderers with the JsonForms component:
import { JsonForms } from '@jsonforms/react';
import { materialRenderers, materialCells } from '@jsonforms/material-renderers';
import { dataSourceRenderer } from './DataSourceControl';
import { subdivisionRenderer } from './SubdivisionControl';
import { dateRenderer } from './DateControl';
import { postalCodeRenderer } from './PostalCodeControl';

const customRenderers = [
  dataSourceRenderer,
  subdivisionRenderer,
  dateRenderer,
  postalCodeRenderer,
  ...materialRenderers,
];

const DynamicForm = ({ schema, uiSchema, data }) => (
  <JsonForms
    schema={schema}
    uischema={uiSchema}
    data={data}
    renderers={customRenderers}
    cells={materialCells}
  />
);

See it in action

To see dynamic forms in practice, explore the KYC processes that use them:

KYC Processes

Learn how dynamic forms power KYC data collection.