jonsully1.dev

Schema-Driven Dynamic Components in React

Cover Image for Schema-Driven Dynamic Components in React
Photo byΒ Β  onΒ 
John O'Sullivan
John O'Sullivan
Senior Full Stack Engineer
& DevOps Practitioner

πŸ“– 10 minute read

Most React applications render components that are known at build time. You write <TextInput /> or <DatePicker /> and the compiler resolves them statically. But some applications need to decide which component to render based on data β€” a schema, a configuration object, a backend response. This is sometimes called the dynamic component pattern, and it comes with a trap that will silently break your application if you get it wrong.

This post covers a real-world example: a wizard form engine that renders standard inputs for most fields but swaps in specialised components when the schema says so. I'll walk through the problem, the naive solution that breaks React state, and the correct approach.


The Problem

We're building an internal developer portal with a wizard that creates services from templates. Each template defines its parameters as a JSON schema:

{
  title: 'Basic details',
  required: ['serviceName'],
  properties: {
    serviceName: {
      type: 'string',
      title: 'Service name',
    },
    primaryDomain: {
      type: 'string',
      title: 'Primary domain',
      'ui:field': 'DomainInput',   // ← special annotation
    },
  },
}

Most fields are plain text inputs. But primaryDomain has a ui:field annotation β€” it needs a custom component that validates domain format in real time and shows visual feedback (checkmarks, error messages). The wizard's field renderer needs to decide at runtime: render a standard <input>, or render <DomainInput>.

This is a common pattern in form builders, CMS platforms, low-code tools, and any application where the UI structure is defined by data rather than hardcoded JSX.

The Naive Approach (And Why It Breaks)

The obvious solution is a registry β€” a Record<string, ComponentType> mapping field type names to components. Look up the component at render time, render it:

// ❌ This breaks React state
const fieldRegistry: Record<string, ComponentType<CustomFieldProps>> = {
  DomainInput,
};

function getCustomField(name: string) {
  return fieldRegistry[name];
}

export default function WizardField({ schema, ...rest }: WizardFieldProps) {
  if (schema["ui:field"]) {
    const CustomField = getCustomField(schema["ui:field"]);
    if (CustomField) {
      return <CustomField {...rest} schema={schema} />;
    }
  }

  return <input /* standard field */ />;
}

This looks correct. It works on the first render. Then it breaks everything.

The Error

React (with the compiler or strict mode) throws:

Error: Cannot create components during render

Components created during render will reset their state each time
they are created. Declare components outside of render.

The line it points to is <CustomField β€” React sees that the component reference assigned to CustomField was resolved during the render phase, and it can't guarantee that the same reference will be used on the next render. If the reference changes, React treats it as a different component β€” it unmounts the old one, mounts a new one, and all internal state (useState, useRef, effects) resets.

For a component like DomainInput that tracks validation state in useMemo, this means the validation result gets recalculated and potentially flashes on every parent re-render.

Why useMemo Doesn't Fix It

The next attempt is wrapping the lookup in useMemo:

const CustomField = useMemo(
  () => getCustomField(schema["ui:field"]),
  [schema["ui:field"]],
);

This fails for two reasons.

First, the React compiler can't statically verify that getCustomField returns a stable reference. It sees a function call during render whose result gets used as a JSX element type β€” the exact pattern it flags.

Second, schema["ui:field"] is a computed property access, not a simple expression. The exhaustive-deps rule rejects it: "Expected the dependency list to be an array of simple expressions."

You can extract the dependency (const uiField = schema["ui:field"]), but the compiler error on the component reference remains. useMemo doesn't solve the underlying problem.

The Correct Approach: Static Imports with Conditional Rendering

The fix is straightforward β€” import the component at the top level and use a direct conditional:

import type { TemplatePropertySchema } from "@hive/shared";
import DomainInput from "./DomainInput";

export default function WizardField({
  name,
  schema,
  value,
  error,
  required,
  onChange,
  onBlur,
}: WizardFieldProps) {
  const uiField = schema["ui:field"];

  if (uiField === "DomainInput") {
    return (
      <DomainInput
        name={name}
        schema={schema}
        value={value}
        error={error}
        required={required}
        onChange={onChange}
        onBlur={onBlur}
      />
    );
  }

  // Standard field rendering (text input, select, etc.)
  return (
    <div className="rh-wizard-field">
      {/* ... */}
    </div>
  );
}

Why this works

DomainInput is a top-level import β€” it's resolved once at module load time, not during render. React sees the same function reference on every render cycle. The if (uiField === "DomainInput") check is a value comparison, not a component creation, so React knows the component identity is stable. Because the reference doesn't change between renders, React keeps the existing instance mounted with its internal state intact.

What about the registry?

The registry (fieldRegistry.ts) is still useful as a lookup utility for other consumers β€” tests, documentation generators, or any code that needs to know which custom fields exist. It just shouldn't be the mechanism for resolving components during render:

import type { ComponentType } from "react";
import DomainInput from "./DomainInput";

export interface CustomFieldProps {
  name: string;
  schema: TemplatePropertySchema;
  value: string;
  error?: string;
  required: boolean;
  onChange: (field: string, value: string) => void;
  onBlur: (field: string) => void;
}

const fieldRegistry: Record<string, ComponentType<CustomFieldProps>> = {
  DomainInput,
};

export function getCustomField(
  uiField: string,
): ComponentType<CustomFieldProps> | undefined {
  return fieldRegistry[uiField];
}

The registry stores stable references to imported components. It's an index, not a factory. If you use it in non-render contexts (e.g., if (getCustomField(name)) to check whether a custom field exists), it works fine.

The Custom Component

For completeness, here's the DomainInput component that the wizard renders through this pattern:

"use client";

import { useMemo } from "react";
import { validateDomain } from "./domainValidation";
import { CustomFieldProps } from "./fieldRegistry";

export default function DomainInput({
  name, schema, value, error, required, onChange, onBlur,
}: CustomFieldProps) {
  const validation = useMemo(() => {
    if (!value || value.trim() === "") return null;
    return validateDomain(value);
  }, [value]);

  const formatError = validation && !validation.valid ? validation.message : null;
  const displayError = error || formatError;
  const showSuccess = !!value && validation?.valid && !error;

  return (
    <div className="rh-wizard-field">
      <label htmlFor={`wizard-field-${name}`}>
        {schema.title}
        {required && <span className="rh-wizard-field__required">*</span>}
      </label>

      <div className="rh-domain-input">
        <input
          type="text"
          className={`rh-input${displayError ? " rh-input--error" : ""}${showSuccess ? " rh-input--valid" : ""}`}
          value={value}
          onChange={(e) => onChange(name, e.target.value)}
          onBlur={() => onBlur(name)}
          placeholder="e.g. app.example.com"
          aria-invalid={!!displayError}
        />

        <span className="rh-domain-input__status" aria-live="polite">
          {showSuccess && (
            <span className="rh-domain-input__valid" aria-label="Domain is valid">βœ“</span>
          )}
        </span>
      </div>

      {displayError && (
        <p className="rh-wizard-field__error" role="alert">{displayError}</p>
      )}
    </div>
  );
}

Validation is a pure function β€” no network calls, instant feedback:

const DOMAIN_REGEX = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
const RESERVED_DOMAINS = ['example.com', 'localhost', 'test.local'];

export function validateDomain(value: string): DomainValidationResult {
  if (!DOMAIN_REGEX.test(value)) {
    return { valid: false, message: 'Invalid domain format' };
  }

  const isReserved = RESERVED_DOMAINS.some(
    (r) => value === r || value.endsWith(`.${r}`),
  );

  if (isReserved) {
    return { valid: false, message: 'This domain is reserved' };
  }

  return { valid: true };
}

The useMemo in DomainInput only works correctly because the component itself is stable β€” React doesn't remount it on every parent re-render, so the memoised validation result persists as expected.

When You'd Use This Pattern

This comes up more often than you'd think. Form builders are the most obvious case β€” the UI structure comes from a JSON schema, and some fields need specialised components like colour pickers, rich text editors, or file uploaders. CMS rendering is another: content blocks defined in a headless CMS each map to a different React component. Dashboard builders, low-code platforms, and plugin systems all hit the same problem β€” data tells you which component to render, but the component itself needs to maintain stable state across re-renders.

Things to Keep in Mind

Import components at the top level. If a component will be rendered in JSX, import it statically. Don't resolve it from a map, function call, or dynamic expression during render.

Use conditionals for JSX element types, not dynamic variables. if (type === "DomainInput") return <DomainInput /> is safe. const C = registry[type]; return <C /> is not β€” at least not in the render path.

Registries are fine for metadata, just not for rendering. A registry is useful for listing available components, checking whether a component exists, or providing type information. It just shouldn't be the mechanism to resolve component references that end up in JSX.

useMemo does not stabilise component references in the eyes of the React compiler. The compiler performs static analysis β€” it can't verify that a function call inside useMemo returns the same constructor. Only top-level imports and module-scope constants are trusted.

Finally, test state preservation explicitly. If a custom component has internal state (validation results, loading indicators, user input), write a test that re-renders the parent and asserts the child's state is preserved. This catches regressions that are invisible in unit tests focused on initial render.


Summary

Dynamic component rendering is a useful pattern for schema-driven UIs, but React ties component identity to reference equality β€” if the reference changes between renders, React destroys and recreates the component, losing all state. The fix is simple: import components at the top level, use conditional rendering instead of dynamic lookups in JSX, and keep your registries for non-render use cases.