Austin Malerba Software Engineer at Target, BS in CS, Immature Amateur

Automatically generate your own React components with plop.js

7 min read 2066

Do you often find yourself copying and pasting old components as templates for new components? I know I do.

Having worked with Angular before, I started to feel sad that there is no React equivalent to the Angular CLI’s generate API. For those who are unfamiliar with it, the Angular CLI offers utilities to generate components, routes, services, and pipes all from the command line.

Then I started thinking, why is there no React equivalent? It occurred to me that the React ecosystem is not as standardized as the Angular ecosystem. A new React app doesn’t ship with much. Directory structure and dependencies are largely left up to the developer. This can be nice, but the lack of uniformity amongst React applications makes tools such as React component generators a little bit harder to tackle.

After some investigation, I stumbled upon a library called plop. Plop lets you define your own parameterized code templates, which can be injected into your source code to generate boilerplate code that you would otherwise have to write yourself.

The neat thing about plop is that it provides a fairly high-level API but is simultaneously very customizable. Our imagination is the bottleneck of what can be generated with this tool. We’re not limited just to components; we can generate containers, services, hooks, reducers, actions, utilities, docs, readme’s, etc.

Project structure

Before we can generate boilerplate code automatically, we need to know what boilerplate we want to generate. Over the years, I’ve come to enjoy the following structure for my React apps, so I’m going to build my generators with this structure in mind.

A few things to note about this setup:

  • components holds our reusable React components
  • pages holds single-use React components (most often rendered by routes)
  • hooks holds our custom React Hooks
  • services holds stateful business logic services
  • index.js files are used to expose interfaces at the directory level
  • Styling is done via CSS Modules (ships with create-react-app)
  • Testing is done via Jest (ships with create-react-app as well)

CLI interface

Now that we know how we want to structure our code, we can focus on the interface of the CLI we are going to build. For the purposes of this article, we will allow the generation of components, pages, hooks, and services. To keep it simple, the only argument we will provide for each is a name.

# Generate a single-use component
npm run generate page <name>
# Generate page called Home
npm run generate page Home

# Generate a reusable component
npm run generate component <name>
# Generate a component called DogCard
npm run generate component DogCard

# Generate a custom hook
npm run generate hook <name>
# Generate a hook called useAsync
npm run generate hook useAsync

# Generate a service
npm run generate service <name>
# Generate a service called petApi
npm run generate service petApi

Configuring plop

Let’s create a new React app and configure plop.

# Create new react app
npx create-react-app react-starter-cli
# Change working directory to root of app
cd react-starter-cli
# Install plop as a dev-dependency
npm install --save-dev plop
# Make a directory for our templates
mkdir plop-templates
# Create a plopfile to hold our cli logic
touch plopfile.js

Once we’ve run these commands, we’ll want to modify plopfile.js to include the following:

module.exports = plop => {
  plop.setGenerator('component', {
    description: 'Create a component',
    // User input prompts provided as arguments to the template
    prompts: [
      {
        // Raw text input
        type: 'input',
        // Variable name for this input
        name: 'name',
        // Prompt to display on command line
        message: 'What is your component name?'
      },
    ],
    actions: [
      {
        // Add a new file
        type: 'add',
        // Path for the new file
        path: 'src/components/{{pascalCase name}}.js',
        // Handlebars template used to generate content of new file
        templateFile: 'plop-templates/Component.js.hbs',
      },
    ],
  });
};

We’ll also need a component template to specify the content of our generated components. We can create this under plop-templates/Component.js.hbs (the .hbs extension indicates that this is a Handlebars.js template).

import React from 'react';

const {{pascalCase name}} = props => {
  return (
    <div>
      {{pascalCase name}}
    </div>
  );
};

export default {{pascalCase name}};

And lastly, we’ll want to add a script to our package.json to create an alias for the plop command.

{
  /* ... */
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "generate": "plop"
  },
  /* ... */
}

Here’s the result of this configuration:

You can see when we try to generate a component on the command line, our prompt kicks in, asking the user for a component name. It then uses the value to populate the name parameter in our Handlebars template. The pascalCase modifier is used to ensure name is formatted in PascalCase. Here’s a full list of available template helpers.

We’re headed in the right direction, but this is still pretty wimpy. Let’s beef up our plopfile.js to provide some really cool functionality.

module.exports = plop => {
  plop.setGenerator('component', {
    description: 'Create a reusable component',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'What is your component name?'
      },
    ],
    actions: [
      {
        type: 'add',
        // Plop will create directories for us if they do not exist
        // so it's okay to add files in nested locations.
        path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.js',
        templateFile:
          'plop-templates/Component/Component.js.hbs',
      },
      {
        type: 'add',
        path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.test.js',
        templateFile:
          'plop-templates/Component/Component.test.js.hbs',
      },
      {
        type: 'add',
        path:
          'src/components/{{pascalCase name}}/{{pascalCase name}}.module.css',
        templateFile:
          'plop-templates/Component/Component.module.css.hbs',
      },
      {
        type: 'add',
        path: 'src/components/{{pascalCase name}}/index.js',
        templateFile: 'plop-templates/Component/index.js.hbs',
      },
      {
        // Adds an index.js file if it does not already exist
        type: 'add',
        path: 'src/components/index.js',
        templateFile: 'plop-templates/injectable-index.js.hbs',
        // If index.js already exists in this location, skip this action
        skipIfExists: true,
      },
      {
        // Action type 'append' injects a template into an existing file
        type: 'append',
        path: 'src/components/index.js',
        // Pattern tells plop where in the file to inject the template
        pattern: `/* PLOP_INJECT_IMPORT */`,
        template: `import {{pascalCase name}} from './{{pascalCase name}}';`,
      },
      {
        type: 'append',
        path: 'src/components/index.js',
        pattern: `/* PLOP_INJECT_EXPORT */`,
        template: `\t{{pascalCase name}},`,
      },
    ],
  })
}

We’ve added several more actions. We don’t want our generated components represented as just single files, we want them represented as directories packaged with all sorts of goodies (tests, styles, etc.).

We’ve added actions to create a new directory for each new component, and we’ve instructed plop to add to the directory not just our component file, but files for tests, styles, and an index file to act as an interface for the component directory.

Additionally, we’ve added a couple actions to append to an existing file, components/index.js. This file will be responsible for packaging all of our components into a neat little module so we can import them elsewhere as follows: import { Foo, Bar } from './components'.

Plop actions with type: 'append' search for a pattern within a file and inject a rendered template after the pattern match locations. For this reason, we ensure a file exists at components/index.js and then we append rendered templates at the locations of plop injection hooks within this file.

Let’s have a look at the updated templates:

/* PLOP_INJECT_IMPORT */

export {
  /* PLOP_INJECT_EXPORT */
}

In the template above, we see the aforementioned plop hooks. Note that these “Hooks” are totally arbitrary, we just need something in our file that we can match against patterns defined within our plop actions.

The remainder of the templates are pretty straightforward as they don’t introduce any new concepts.

import React from 'react';
import PropTypes from 'prop-types';
import styles from './{{pascalCase name}}.module.css';

const {{pascalCase name}} = props => {
  return (
    <div className={styles.root}>
    </div>
  );
};

{{pascalCase name}}.defaultProps = {

};

{{pascalCase name}}.propTypes = {

};

export default {{pascalCase name}};</pre>
<pre>.root {
  
}
import React from 'react';
import {{pascalCase name}} from './{{pascalCase name}}';

describe('{{pascalCase name}}', () => {
   it('renders without error', () => {

   });
});
import {{pascalCase name}} from './{{pascalCase name}}';

export default {{pascalCase name}};

Although it’s not an issue in this example, one thing to be careful of is syntax conflicts between your template content and Handlebars.

For example, if you had a template containing <div style={{ height: 100 }}/>, Handlebars would incorrectly interpret the curly braces as expressions. In this case, you would have to escape the curly braces as follows: <div style=\{{ height: 100 }}/>.

With our updated plopfile, our generator is looking pretty slick.

Now that we’ve built out our component generator, we can build generators for pages, Hooks, and services as well. Building these other generators doesn’t introduce any new concepts, so I will skip ahead to show our final plopfile.

module.exports = plop => {
  plop.setGenerator('component', {
    description: 'Create a reusable component',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'What is your component name?',
      },
    ],
    actions: [
      {
        type: 'add',
        path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.js',
        templateFile:
          'plop-templates/Component/Component.js.hbs',
      },
      {
        type: 'add',
        path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.test.js',
        templateFile:
          'plop-templates/Component/Component.test.js.hbs',
      },
      {
        type: 'add',
        path:
          'src/components/{{pascalCase name}}/{{pascalCase name}}.module.css',
        templateFile:
          'plop-templates/Component/Component.module.css.hbs',
      },
      {
        type: 'add',
        path: 'src/components/{{pascalCase name}}/index.js',
        templateFile: 'plop-templates/Component/index.js.hbs',
      },
      {
        type: 'add',
        path: 'src/components/index.js',
        templateFile: 'plop-templates/injectable-index.js.hbs',
        skipIfExists: true,
      },
      {
        type: 'append',
        path: 'src/components/index.js',
        pattern: `/* PLOP_INJECT_IMPORT */`,
        template: `import {{pascalCase name}} from './{{pascalCase name}}';`,
      },
      {
        type: 'append',
        path: 'src/components/index.js',
        pattern: `/* PLOP_INJECT_EXPORT */`,
        template: `\t{{pascalCase name}},`,
      },
    ],
  })

  plop.setGenerator('page', {
    description: 'Create a page',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'What is your page name?',
      },
    ],
    actions: [
      {
        type: 'add',
        path: 'src/pages/{{pascalCase name}}/{{pascalCase name}}.js',
        templateFile:
          'plop-templates/Page/Page.js.hbs',
      },
      {
        type: 'add',
        path: 'src/pages/{{pascalCase name}}/{{pascalCase name}}.test.js',
        templateFile:
          'plop-templates/Page/Page.test.js.hbs',
      },
      {
        type: 'add',
        path:
          'src/pages/{{pascalCase name}}/{{pascalCase name}}.module.css',
        templateFile:
          'plop-templates/Page/Page.module.css.hbs',
      },
      {
        type: 'add',
        path: 'src/pages/{{pascalCase name}}/index.js',
        templateFile: 'plop-templates/Page/index.js.hbs',
      },
      {
        type: 'add',
        path: 'src/pages/index.js',
        templateFile: 'plop-templates/injectable-index.js.hbs',
        skipIfExists: true,
      },
      {
        type: 'append',
        path: 'src/pages/index.js',
        pattern: `/* PLOP_INJECT_IMPORT */`,
        template: `import {{pascalCase name}} from './{{pascalCase name}}';`,
      },
      {
        type: 'append',
        path: 'src/pages/index.js',
        pattern: `/* PLOP_INJECT_EXPORT */`,
        template: `\t{{pascalCase name}},`,
      },
    ],
  })

  plop.setGenerator('service', {
    description: 'Create service',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'What is your service name?',
      },
    ],
    actions: [
      {
        type: 'add',
        path: 'src/services/{{camelCase name}}.js',
        templateFile: 'plop-templates/service.js.hbs',
      },
      {
        type: 'add',
        path: 'src/services/index.js',
        templateFile: 'plop-templates/injectable-index.js.hbs',
        skipIfExists: true,
      },
      {
        type: 'append',
        path: 'src/services/index.js',
        pattern: `/* PLOP_INJECT_IMPORT */`,
        template: `import {{camelCase name}} from './{{camelCase name}}';`,
      },
      {
        type: 'append',
        path: 'src/services/index.js',
        pattern: `/* PLOP_INJECT_EXPORT */`,
        template: `\t{{camelCase name}},`,
      }
    ],
  })

  plop.setGenerator('hook', {
    description: 'Create a custom react hook',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'What is your hook name?',
      },
    ],
    actions: [
      {
        type: 'add',
        path: 'src/hooks/{{camelCase name}}.js',
        templateFile: 'plop-templates/hook.js.hbs',
      },
      {
        type: 'add',
        path: 'src/hooks/index.js',
        templateFile: 'plop-templates/injectable-index.js.hbs',
        skipIfExists: true,
      },
      {
        type: 'append',
        path: 'src/hooks/index.js',
        pattern: `/* PLOP_INJECT_IMPORT */`,
        template: `import {{camelCase name}} from './{{camelCase name}}';`,
      },
      {
        type: 'append',
        path: 'src/hooks/index.js',
        pattern: `/* PLOP_INJECT_EXPORT */`,
        template: `\t{{camelCase name}},`,
      }
    ],
  })
}

There you have it. This plopfile will let us generate components, pages, hooks, and services. The above code references many template files; I’ve decided there are too many of them to show them all in gists, but you can view them all in the react-starter-cli repository.

Here’s the final thing in action:

Next steps

So, we have a super awesome and highly personalized React CLI, but where do we go next? Here are some ways you might wish to extend this plop configuration:

  • Add a prompt to component generation to specify whether the component should be a class component or a functional component
  • Instead of hardcoding components to go into a /components directory, add a prompt to dynamically input a directory where a component should be created
  • Add validation to user input (e.g., minimum length requirement)
  • Populate propTypes and/or defaultProps via prompts
  • Add generators for action types, actions, and reducers
  • Use plop with other frameworks, not just React!
Austin Malerba Software Engineer at Target, BS in CS, Immature Amateur

3 Replies to “Automatically generate your own React components with plop.js”

Leave a Reply