Search code examples
reactjsecmascript-6reduxplugin-pattern

Plugin/widgets pattern in React/Redux to add components dynamically


I have an application which uses React/Redux. I use Electron to package it as a standalone app. I am currently working on building the base to allow users to add their own plugin or widget to the app. Since I'm using Redux, I would like to keep using Redux to keep track of what is installed and the changes/new functionality it brings to the application.

Before I dive into my question, let's take an example. My core application has a sidebar with "control" buttons. The core has two buttons, but I'd like to allow plugins to add their own buttons there. Of course, manually I could go ahead, import the plugin in the js file of the button sidebar component and add the buttons manually, but this would defeat the pluggability purpose. So the best and most simple solution I can think of is to dispatch an action from the plugin code, such as ADD_SIDEBAR_BUTTON which basically adds an object with the metadata of the button to an array of buttons (including those that are core.) Something like this:

store.dispatch({type: "ADD_SIDEBAR_BUTTON", payload: {type: "button", path: "goSomewhere", label: "A label"}});

While this can work, it limits what the button can do greatly, as the actual logic and jsx for the button will come from the core application. In practice, I'm just itching to have the plugin pass a React component, which would give the plugin much freedom in terms of events, full control of the lifecycle, etc, ... But passing anything else than plain objects is a Redux no-no.

Another solution would be to let plugin developers create a file, with an exported class, and provide the path to it with redux, as follows:

class MyPluginButton extends Component {
   handleClick() => evt => {
     // do my special things
   }
   render() {
     return <button onClick={this.handleClick}>My Special Button</button>
   }
}

export default MyPluginButton;

An initialize() function of the plugin would then dispatch the action and pass the information needed for the core to import the button (I can also think of security issues with this potentially if not done properly):

store.dispatch({type: "ADD_SIDEBAR_BUTTON", payload: {path: "myplugin/src/components/MyPluginButton"}});

Now to my question, does anyone have good ideas how this could/should be implemented? I want to make sure there's no other pattern or implementation I have missed.


Solution

  • What I ended up doing is a pluginRegistration module as follows, succintly, with the basic idea found on a blog I cannot find again:

    const _registeredComponents = {};
    
    export const registerComponent = (pluginName, component, injectAction) => {
    
      _registeredComponents[`plugin_${pluginName}_${component.PLUGIN_COMPONENT_NAME}`] = {pluginName: pluginName, component: component, action: injectAction};
    
    };
    
    export const getRegisteredComponents = () => {
       return {..._registeredComponents};
    };
    
    export const getRegisteredComponent = fullPluginComponentName => {
       return _registeredComponents[fullPluginComponentName].component;
    }
    

    The component that wants to get added registers itself with the function above and passes an action. From my main App component inside ComponentDidMount, I then loop through all the entries in _registeredComponents, dispatch the action passed along the component class ref to add my button (in the store, the key string) to an existing ControlButtons component. After that, the parent control buttons component gets the fullPluginComponentName from the state and instantiates the component from the reference. And voila, a fully dynamic React component instantiation/injection using Redux, but keeping only plain objects in the store.

    My button component is pretty ordinary, except for the static getter added (using constructor.name is tempting, but when minifying JS the names get lost and it might lead to issues.)

    export class MyButtonComponent extends Component {
      static get PLUGIN_COMPONENT_NAME() {
        return "MyButtonComponent";
      }
    
      render() {
       return (<button>My Useless Button</button>);
      }
    }
    

    Inside the same file where I create my button to be injected React class, I simply register the component, as follows:

    registerComponent("MyPluginName", MyButtonComponent, actions.addButtonToControls);
    

    This ties together a plugin name, an actual component class reference, and the action that I want triggered.

    In order to get the keys of the component entries available for consumption, my main App component loops through the registered component entries after mounting:

    class App extends Component {
       componentDidMount() {
           let pluginComponents = getRegisteredComponents();
           for (let fullPluginComponentName in pluginComponents) {
               let entry = pluginComponents[fullPluginComponentName];
               this.props.dispatch({type: entry.action, payload: {pluginName: entry.pluginName, fullPluginNameComponent: fullPluginComponentName}}); 
           }
       }
       render() { ... }
    }
    

    This will have the effect to add the component keys to the store that will be used to get a hold of the class references later on in the ControlButtons, a container for an array of dynamically loaded buttons. Here's a brief version of how the latter loads them from the store:

    class ControlButtons extends Component {
        getDynamicButtons() {
            // this.props.dynamicButtons is an array stored in redux and populated from the looped dispatch-es calls for each registered component.
            return this.props.dynamicButtons.map(componentEntry => {
                let MyDynamicButton = getRegisteredComponent(componentEntry.fullPluginComponentName);
                return <MyDynamicButton />;
            });
        }
        render() {
          <div>
             {this.getDynamicButtons()}
          </div>
        }
    }
    

    Of course, somehow, the file containing the definition of the button class must be imported. This is the next step of my journey, I'll probably also hold an array of modules/file names and use require to load them. But that's another story.