Dynamically inject data in React Router Routes
Asked Answered
M

3

6

I've been working on trying to modularize my React.js app (that will be delivered as a Desktop app with Electron) in a way that if I make a new module in the future, I can just add a new folder and modify a couple of files and it should integrate fine. I got originally inspired by this article: https://www.nylas.com/blog/react-plugins/

After that point, I started doing as much research as I could and ended up creating a JSON file that would live in the server with a manifest of the plugins that are registered for that specific client. Something like this:

{
    "plugins": [
        {
            "name": "Test Plugin",
            "version": "0.0.1",
            "path": "testplugin",
            "file": "test",
            "component":"TestPlugin"
        },
        {
            "name": "Another Plugin",
            "version": "0.0.1",
            "path": "anothertest",
            "file": "othertest",
            "component":"TestPluginDeux"
        }
    ]
}

After that, I made a couple folders that match the path value and that contain a component that matches the name in the manifest (e.g. testplugin/test.jsx that exports the TestPlugin component as a default). I also made a pluginStore file that reads the manifest and mounts the plugins in the this.state.

Then, did a ton of research on Google and here and found this answer: React - Dynamically Import Components

With that function, I was able to iterate through the manifest, find the folders in the directory, and mount the plugins in the this.state by running the mountPlugins() function I had created in the pluginStore, inside a componentDidMount() method in my homepage.

So far so good. I'm using React-Router and I was able to mount the plugins dynamically in the State and able to load them in my Home Route by just calling them like this: <TestPlugin />.

The issue that I have now, is that I wanted to dynamically create Routes that would load these components from the state, either by using the component or the render method, but I had no luck. I would always get the same result... Apparently I was passing an object instead of a String.

This was my last iteration at this attempt:

{this.state.modules.registered.map((item) =>
<Route exact path={`/${item.path}`} render={function() {
  return <item.component />
  }}></Route>
)}

After that, I made a Route that calls a PluginShell component that is called by a Navlink that sends the name of the plugin to inject and load it dynamically.

<Route exact path='/ex/:component' component={PluginShell}></Route>

But I ended having the same exact issue. I'm passing an object and the createElement function expected a string.

I searched all over StackOverflow and found many similar questions with answers. I tried applying all the possible solutions with no luck.

EDIT: I have put together a GitHub repo that has the minimal set of files to reproduce the issue.

Here's the link: https://codesandbox.io/embed/aged-moon-nrrjc

Mild answered 15/8, 2019 at 16:34 Comment(7)
Are you using dynamic imports import()? It returns a promise that resolves to an object. If you are exporting using export default MyComponent it will resolve to an object { default: MyComponent }.Esophagus
@RamilAmparo, I am using a dynamic import inside a componentDidMount() method. And inside the dynamic import, I'm running a function called "mountPlugins" that adds them to the this.state. The components are exported as default.Mild
Can we see what pluginStore.getAll().registered.plugins is returning. It might be possible that it is not returning a valid component.Esophagus
@RamilAmparo updated question with screenshots. :)Mild
Instead of posting partial code and screenshots, please include a mwe: stackoverflow.com/help/minimal-reproducible-example. A github repo would be ideal, even if it's stripped down to pure components without any styling.Macrophage
@MattCarlotta Thank you for the suggestion! I'll do that now. :)Mild
Updated question with GitHub repo with src files.Mild
M
5

Okey pokey. There are a lot of moving parts here that can be vastly simplified.

  1. I'd recommend moving toward a more developer-friendly, opinionated state store (like Redux). I've personally never used Flux, so I can only recommend what I have experience with. As such, you can avoid using plain classes for state management.
  2. You should only import the modules ONCE during the initial application load, then you can dispatch an action to store them to (Redux) state, then share the state as needed with the components (only required if the state is to be shared with many components that are spread across your DOM tree, otherwise, not needed at all).
  3. Module imports are asynchronous, so they can't be loaded immediately. You'll have to set up a condition to wait for the modules to be loaded before mapping them to a Route (in your case, you were trying to map the module's registered string name to the route, instead of the imported module function).
  4. Module imports ideally should be contained to the registered modules within state. In other words, when you import the module, it should just overwrite the module Component string with a Component function. That way, all of the relevant information is placed within one object.
  5. No need to mix and match template literals with string concatenation. Use one or the other.
  6. Use the setState callback to spread any previousState before overwriting it. Much simpler and cleaner looking.
  7. Wrap your import statement within a try/catch block, otherwise, if the module doesn't exist, it may break your application.

Working example (I'm just using React state for this simple example, I also didn't touch any of the other files, which can be simplified as well):

Edit wispy-thunder-jtc6c


App.js

import React from "react";
import Navigation from "./components/MainNavigation";
import Routes from "./routes";
import { plugins } from "./modules/manifest.json";
import "./assets/css/App.css";

class App extends React.Component {
  state = {
    importedModules: []
  };

  componentDidMount = () => {
    this.importPlugins();
  };

  importPlugins = () => {
    if (plugins) {
      try {
        const importedModules = [];
        const importPromises = plugins.map(plugin =>
          import(`./modules/${plugin.path}/${plugin.file}`).then(module => {
            importedModules.push({ ...plugin, Component: module.default });
          })
        );

        Promise.all(importPromises).then(() =>
          this.setState(prevState => ({
            ...prevState,
            importedModules
          }))
        );
      } catch (err) {
        console.error(err.toString());
      }
    }
  };

  render = () => (
    <div className="App">
      <Navigation />
      <Routes {...this.state} />
    </div>
  );
}

export default App;

routes/index.js

import React from "react";
import React from "react";
import isEmpty from "lodash/isEmpty";
import { Switch, Route } from "react-router-dom";
import ProjectForm from "../modules/core/forms/new-project-form";
import NewPostForm from "../modules/core/forms/new-post-form";
import ProjectLoop from "../modules/core/loops/project-loop";
import Home from "../home";

const Routes = ({ importedModules }) => (
  <Switch>
    <Route exact path="/" component={Home} />
    <Route exact path="/projectlist/:filter" component={ProjectLoop} />
    <Route exact path="/newproject/:type/:id" component={ProjectForm} />
    <Route exact path="/newpost/:type" component={NewPostForm} />
    {!isEmpty(importedModules) &&
      importedModules.map(({ path, Component }) => (
        <Route key={path} exact path={`/${path}`} component={Component} />
      ))}
  </Switch>
);

export default Routes;
Macrophage answered 18/8, 2019 at 1:12 Comment(3)
Oh, wow! Thank you so much! I got my question edited, but it said originally that I'm new to React, and clearly it shows. I appreciate your help so much! I just implemented it my code and it works perfectly. I will also do research and try to fix the rest of the files based on your suggestions. :)Mild
Also, I actually was wondering about the asynchronous import. I figured that if they were loading with a chunk, I would probably need a different function that would catch them after they were mounted. I'm reviewing all the changes you provided, trying to learn from it.Mild
I'd recommend starting with something more simple in nature before moving into async module imports. If you're new to React, I'd highly recommend this course: udemy.com/share/1000NiA0UZdFlWQno= (it's currently only $20, covers most things React/Redux/Webpack and is a worthwhile investment, especially if you're serious about learning web development).Macrophage
D
2

You've got the right idea, if anything I guess your syntax is slightly off. I didn't have to tweak much from your example to get dynamic routing work.

Here's a working example of what I think you want to do:

const modules = [{
  path: '/',
  name: 'Home',
  component: Hello
},{
  path: '/yo',
  name: 'Yo',
  component: Yo
}];


function DynamicRoutes() {
    return (
        <BrowserRouter>
            { modules.map(item => <Route exact path={item.path} component={item.component}/>) }
        </BrowserRouter>
    );
}

https://stackblitz.com/edit/react-zrdmcq

Die answered 17/8, 2019 at 18:26 Comment(6)
Thank you for your input! I matched my structure to your fiddle (used Link instead of Navlink, added exact attributes, used same map() function structure and still no luck. This is what I get in the console: Warning: <TestPlugin /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements. in TestPlugin (created by Context.Consumer)Mild
@AlfieRobles If you are using custom react components, the components must start in a capital letter <Component /> not <component />. If it starts with a lower case, react might confuse it for an HTML element such as <div />Esophagus
Could you show us what TestPlugin looks like? React doesn't recognise it as a React Component.Die
@RamilAmparo I changed the case. Both in my manifest.json (now it's "Component": "TestPlugin" and inside my map() function component={item.Component}. Warning is gone, but still not loading.Mild
@Die this is what it looks like. const TestPlugin = () => ( <div className='home'> <h1>I'M A TEST PLUGIN! :D</h1> </div> ); export default TestPlugin;Mild
I updated the question with samples of what the code looks like.Mild
N
1

I think the problem is the way you are trying to render <item.component /> but not sure, did you get the same error whit this?

Try:

<Route exact path={`/${item.path}`} render={function() {
  return React.createElement(item.component, props)
  }}></Route>
)}
Nailbrush answered 17/8, 2019 at 18:20 Comment(3)
Thanks for your input! I tested your suggestion as a copy/paste and throws props as undefined, removed it and no visible errors, but doesn't work. I get this in the console: Warning: <TestPlugin /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements. in TestPlugin (created by Context.Consumer)Mild
Yes, my fault to put props wanted to be a reference to show how to pass props in the casayou got those, but for the porpouse should avoid that. Can you try changing component name to Component?Nailbrush
I changed the case. Both in my manifest.json (now it's "Component": "TestPlugin" and inside my map() function (component={item.Component}). Warning is gone, but still not loading.Mild

© 2022 - 2024 — McMap. All rights reserved.