Skip to content

Instantly share code, notes, and snippets.

@amit08255
Last active December 10, 2024 17:46
Show Gist options
  • Save amit08255/ed0f19e08d545b20261bed50d431085c to your computer and use it in GitHub Desktop.
Save amit08255/ed0f19e08d545b20261bed50d431085c to your computer and use it in GitHub Desktop.
React dynamic component rendering with JSON

Rendering React Components Dynamically with JSON

Approach 1: React component renderer with constants

{
    "type": "Container",
    "data": {
        "id": "4400936b-6158-4943-9dc8-a04c57e1af46",
        "items": [
            {
                "type": "Card",
                "data": {
                    "id": "26b3f355-2f65-4aae-b9fd-609779f24fdd",
                    "title": "A card example",
                    "subtitle": "A subtitle",
                    "items": [
                        {
                            "type": "Button",
                            "data": {
                                "id": "4400936b-6158-4943-9dc8-a04c57e1af46",
                                "title": "Button text",
                                "className": "btn-primary",
                                "action": {
                                    "type": "call",
                                    "url": "https://pokeapi.co/api/v2/"
                                },
                            }
                        },
                    ]
                }
            },
            {
                "type": "Divider",
                "data": {
                    "id": "4400936b-6158-4943-9dc8-a04c57e1af46",
                    "marginX": 3,
                }
            },
            {
                "type": "Card",
                "data": {
                    "id": "4400936b-6158-4943-9dc8-a04c57e1af46",
                    "title" : "Title",
                    "headline" : "Month ## - Month ##, ####",
                    "copy": "A really long text....",
                    "image" : {
                        "url" : "https://i.stack.imgur.com/y9DpT.jpg"
                    },
                }
            },
            {
                "type": "Container",
                "data": {
                    "id": "d76e3a5f-01ad-46f6-a45d-3ad9699ecf99",
                    "fluid": true,
                    "embeddedView": {
                        "type": "Input",
                        "data": {
                            "id": "26b3f355-2f65-4aae-b9fd-609779f24fdd",
                            "label": "Input",
                            "type": "password",
                            "placeholder": "Password",
                            "isRequired": false,
                            "minCharactersAllowed": 1,
                            "maxCharactersAllowed": 100,
                            "validations": [
                                {
                                    "regexType": "eightOrMoreCharacters",
                                    "regexErrorCopy": "Use 8 or more characters"
                                },
                            ]
                        }
                    }
                }
            }
        ]
    }
}
// dynamic-rendering.interfaces.ts

// Here is a type to map all the components names.
type ComponentList =
    | 'Button'
    | 'Card'
    | 'Container'
    | 'Divider'
    | 'Input';

export interface IComponent {
    type: ComponentList;
    data: {
        id: string;
        embeddedView?: IComponent;
        items?: Array<IComponent>;
        [key: string]: unknown;
    };
}
// dynamic-rendering.constants.ts

// All the component imports

export const Components = {
    Button,
    Card,
    Container,
    Divider,
    Input,
};
// dynamic-rendering.service.ts
import React from 'react';
import { IComponent } from './dynamic-rendering.interfaces';
import { Components } from './dynamic-rendering.constants';

export function createPage(data?: IComponent): React.ReactNode {
    // Don't render anything if the payload is falsey.
    if (!data) return null;

    function createComponent(item: IComponent): React.ReactNode {
        const { data, type } = item;
        const { items, embeddedView, id, ...rest } = data;
        return React.createElement(
            // TODO: This can be improved
            Components[type] as any,
            {
                // Pass all the props coming from the data object.
                ...rest,
                id,
                // Make each react key unique
                key: id,
            } as any,
            // Map if there are items, if not try to render the embedded view as children
            Array.isArray(items)
                ? items.map(renderer)
                : renderer(embeddedView ?? null),
        );
    }

    // Don't render anything if the payload is falsey.
    function renderer(
        config: IComponent | null,
    ): React.ReactNode {
        if (!config) return null;

        return createComponent(config);
    }

    return renderer(data);
}

Approach 2: Dynamic import

// components/DynamicComponentLoader.js

import React, { Suspense } from 'react';

// Define a map (registry) to map component names to their corresponding dynamic imports
const componentRegistry = {
  Input: () => import('your-ui-library/Input'),
  Button: () => import('your-ui-library/Button'),
  Grid: () => import('your-ui-library/Grid'),
  // Add more components as needed
};

// Dynamic component loader that handles 'element' type differently
const DynamicComponentLoader = ({ type, componentName, componentProps, content }) => {
  // If the type is "element", render it as a raw HTML element
  if (type === 'element') {
    const Element = componentName;
    return <Element {...componentProps}>{content}</Element>;
  }

  // Look up the component in the registry
  const componentLoader = componentRegistry[componentName];

  if (!componentLoader) {
    console.error(`Component "${componentName}" is not registered.`);
    return null;
  }

  // Lazily load the component
  const LazyComp = React.lazy(componentLoader);

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComp {...componentProps}>{content}</LazyComp>
    </Suspense>
  );
};

export default DynamicComponentLoader;
// App.js

import React from 'react';
import DynamicComponentLoader from './components/DynamicComponentLoader';

const jsonData = [
  {
    "id": "mobilePhone",
    "type": "field",
    "component": "Input",
    "componentProps": {
      "path": "cellNumber",
      "readOnly": "true",
      "label": {
        "id": "gw-pe-components-platform-react.PhoneDetails.Mobile Phone",
        "defaultMessage": "meta.Mobile Phone"
      },
      "contentContainerClassName": "mobilePhoneInputContainer"
    }
  },
  {
    "id": "button",
    "component": "Button",
    "type": "action",
    "componentProps": {
      "size": "large",
      "icon": "save",
      "type": "secondary",
      "iconPosition": "right"
    },
    "content": "Button"
  },
  {
    "id": "codelessForm",
    "title": "Codeless form",
    "subtitle": "A form generated from json metadata",
    "type": "page",
    "dataSource": "ConstructionDetails",
    "componentProps": {},
    "componentLayout": {
      "component": "Grid",
      "componentProps": {
        "gap": "xlarge"
      }
    },
    "content": [
      {
        "id": "test15161788",
        "type": "action",
        "component": "Button",
        "content": "Click Me",
        "componentProps": {
          "size": "large",
          "icon": "twitter"
        }
      },
      {
        "id": "heading1",
        "type": "element",
        "component": "h1",
        "componentProps": {
          "className": "heading-class"
        },
        "content": "Dynamic Heading"
      }
    ]
  }
];

function App() {
  return (
    <div className="App">
      {jsonData.map(item => (
        <DynamicComponentLoader
          key={item.id}
          type={item.type}           // Pass the type field from the JSON
          componentName={item.component}
          componentProps={item.componentProps}
          content={item.content}
        />
      ))}
    </div>
  );
}

export default App;
[
  {
    "id": "mobilePhone",
    "type": "field",
    "component": "Input",
    "componentProps": {
      "path": "cellNumber",
      "readOnly": "true",
      "label": {
        "id": "gw-pe-components-platform-react.PhoneDetails.Mobile Phone",
        "defaultMessage": "meta.Mobile Phone"
      },
      "contentContainerClassName": "mobilePhoneInputContainer"
    }
  },
  {
    "id": "button",
    "component": "Button",
    "type": "action",
    "componentProps": {
      "size": "large",
      "icon": "save",
      "type": "secondary",
      "iconPosition": "right"
    },
    "content": "Button"
  },
  {
    "id": "codelessForm",
    "title": "Codeless form",
    "subtitle": "A form generated from json metadata",
    "type": "page",
    "dataSource": "ConstructionDetails",
    "componentProps": {},
    "componentLayout": {
      "component": "Grid",
      "componentProps": {
        "gap": "xlarge"
      }
    },
    "content": [
      {
        "id": "test15161788",
        "type": "action",
        "component": "Button",
        "content": "Click Me",
        "componentProps": {
          "size": "large",
          "icon": "twitter"
        }
      },
      {
        "id": "heading1",
        "type": "element",
        "component": "h1",
        "componentProps": {
          "className": "heading-class"
        },
        "content": "Dynamic Heading"
      }
    ]
  }
]

Idea: The application structure which means the render layout to be completely controlled by server. Every action taken by the user such as button click or input change should push an event to the server and rerenders the component on the response. For that the JSON will have "actionEvents" array which will have the event type such as onclick, onchange, and the name of event to push to server. For this the element will be rendered in a wrapper which will pass these onClick, onChange handlers which will push event to server.

Approach 3: With dynamic event handling

import React, { Suspense, lazy } from 'react';

// Predefined component mapping with dynamic imports
const componentRegistry = {
  Button: lazy(() => import('./components/Button')),
};

// Helper to resolve components from registry or fallback to HTML tag
const resolveComponent = (type) =>
  componentRegistry[type] ||
  ((props) => React.createElement(type, props, props.children || null));

// Event dispatcher function
const dispatchEvent = async (eventName, payload) => {
  try {
    await fetch('/api/events', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ event: eventName, data: payload }),
    });
  } catch (error) {
    console.error('Failed to dispatch event:', error);
  }
};

// Sanitize event arguments to remove circular references
const sanitizeEventArgs = (args) => {
  if (!args || args.length === 0) return null;

  const seen = new WeakSet();

  const sanitize = (obj) => {
    if (obj === null || typeof obj !== 'object') return obj;
    if (seen.has(obj)) return '[Circular]'; // Prevent circular references
    seen.add(obj);

    if (obj instanceof Event) {
      // Extract relevant event properties
      return {
        type: obj.type,
        target: obj.target && {
          tagName: obj.target.tagName,
          id: obj.target.id,
          className: obj.target.className,
          value: obj.target.value,
        },
        currentTarget: obj.currentTarget && {
          tagName: obj.currentTarget.tagName,
          id: obj.currentTarget.id,
          className: obj.currentTarget.className,
        },
        timeStamp: obj.timeStamp,
      };
    }

    if (Array.isArray(obj)) {
      return obj.map(sanitize); // Recursively sanitize array elements
    }

    // Handle general objects
    return Object.fromEntries(
      Object.entries(obj).map(([key, value]) => [key, sanitize(value)])
    );
  };

  return args.map(sanitize);
};


// Bind events with event dispatcher
const bindEventHandlers = (props) => {
  const boundProps = { ...props };
  Object.keys(props).forEach((key) => {
    if (key.startsWith('on') && typeof props[key] === 'string') {
      const eventName = props[key];
      boundProps[key] = (...args) => {
        const sanitizedArgs = sanitizeEventArgs(args);
        dispatchEvent(eventName, sanitizedArgs);
      };
    }
  });
  return boundProps;
};

// Render components based on JSON layout
const renderComponent = (componentConfig) => {
  const { type, props, children } = componentConfig;

  const Component = resolveComponent(type);
  const boundProps = bindEventHandlers(props);

  return (
    <Suspense fallback={<div>Loading {type}...</div>}>
      <Component {...boundProps}>
        {children &&
          children.map((childConfig, index) => (
            <React.Fragment key={index}>
              {renderComponent(childConfig)}
            </React.Fragment>
          ))}
      </Component>
    </Suspense>
  );
};

// Example JSON layout
const jsonLayout = {
  type: 'div',
  props: {
    title: 'Event-Driven UI',
    content: 'This card uses event-driven development.',
  },
  children: [
    {
      type: 'Button',
      props: {
        label: 'Click Me',
        onClick: 'buttonClicked', // Event name
      },
    },
    {
      type: 'div',
      props: { style: { color: 'blue' }, children: 'This is a fallback div.' },
    },
  ],
};

export function App() {
  const [layout, setLayout] = React.useState(jsonLayout);

  // Fetch JSON from API
  React.useEffect(() => {
    const fetchLayout = async () => {
      const response = await fetch('/api/layout');
      const data = await response.json();
      setLayout(data);
    };
    fetchLayout();
  }, []);

  return <div className="App">{renderComponent(layout)}</div>;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment