Use Snowpack for ultra fast dev builds with Create React App

  • October 22, 2020
  • 4 min read
  • GitHub
Long ago I gave up the ways of customizing webpack and embraced create-react-app (CRA) as a someone-else-has-solved-it-for-me way to live. It's been great, truly. But could it be better? At least Rich Harris seems to think so— he points to Snowpack (or similarly minded tech) as the way forward.
What makes Snowpack special? According to snowpack.dev:
Snowpack leverages JavaScript’s native module system (known as ESM) to create a first-of-its-kind build system that never builds the same file twice. Snowpack pushes changes instantly to the browser, saving you hours of development time traditionally spent waiting around for your bundler.
This architecture makes Snowpack fantastic for fast rebuilds during dev, but leaves work to be done for production builds if you want your coded bundled. To do so requires configuring the @snowpack/plugin-webpack, which I'm entirely uninterested in doing. But hey, CRA does a good enough job with bundling prod builds, so why not keep using it for prod and only use Snowpack for dev? That is the setup this post aims to share.

So what do we actually have to do?

Turns out, not very much. Feel free to jump ahead and view the end product with all required changes as a PR on GitHub.

Step 1. Add two snowpack dependencies

npm install snowpack @snowpack/app-scripts-react
Note that @snowpack/app-scripts-react includes support for dotenv and react fast refresh. If you're using SCSS or other extensions, you may have to add more plugins.

Step 2. Add snowpack.config.js to your root dir

The most basic snowpack.config.js contains the following:
module.exports = {
  extends: '@snowpack/app-scripts-react',

  devOptions: {
    port: 3000,
    src: 'src',
    bundle: false,
    fallback: 'index.html',
  },

  installOptions: {
    polyfillNode: true,
  },
};
The example in the repo additionally shows how to configure a proxy, where to add additional plugins, and how to emulate baseUrl: "." from a jsconfig.json.

Step 3. (Optionally) Add babel.config.json to your root dir

This step is only required if you do not use the .jsx extension for files containing JSX. If you put JSX in .js files, you'll need babel to build your code, which Snowpack knows to use by the presence of this file (or .babelrc). If all your JSX lives in .jsx files, Snowpack can handle it directly without any babel config.
{
  "presets": ["@babel/preset-react"]
}

Step 4. (Optionally) Set up postcss

Your app may work fine without making these changes, but they were a bit of a struggle to get right, so I'm sharing them here. In order to match create-react-app's CSS transformations and interpretations of @imports, we need to use postcss.
First, we need to install some dependencies:
npm install postcss-cli postcss-flexbugs-fixes postcss-import postcss-normalize postcss-preset-env
Note: PostCSS 8 only recently came out and not everything is updated to work with it yet, so I actually specified [email protected]^7.1.2 and [email protected]^12.0.1 when I wrote this.
Next, we modify snowpack.config.js to include plugins
module.exports = {
  ...
  plugins: [
    // use postcss to get relative imports working and to match CRA
    [
      '@snowpack/plugin-build-script',
      { cmd: 'postcss $FILE', input: ['.css'], output: ['.css'] },
    ],
  ],
}
Finally, we add postcss.config.js
module.exports = (ctx) => {
  return {
    plugins: {
      // need this to get relative @import "./Foo.css"; working
      'postcss-import': { root: ctx.file.dirname },

      // copied from create-react-app
      'postcss-flexbugs-fixes': {},
      'postcss-preset-env': {
        autoprefixer: {
          flexbox: 'no-2009',
        },
        stage: 3,
      },
      'postcss-normalize': {},
    },
  };
};
You'll see this is a bit atypical for a postcss config, but we need to know the current file when running postcss-import to get relative imports resolved correctly. This is also why we set postcss $FILE as the cmd in snowpack.config.js above as opposed to just postcss as the Snowpack docs show.

Step 5. Configure your start script in package.json

I keep the CRA start script around just in case, but you shouldn't need it anymore.
"start": "snowpack dev --config snowpack.config.js",
"start:cra": "react-scripts start",

Step 6. Update index.html to include the snowpack dev entrypoint

Things get a bit weird here. What we want is code that the CRA minifier will remove during production builds, but will still be run when using the snowpack dev server.
The trick is to use a snowpack specific interpolation variable called %MODE% (essentially the same as process.env.NODE_ENV). This interpolation only exists in the snowpack dev server, not in CRA, so the if statement will be analyzed as dead code and be pruned during the prod builds.
<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>

  <!-- 
    add entry for snowpack, only used in dev, it is pruned in production.
  -->
  <script>
    if ('%MODE%' === 'development') {
      // we wrap this in a function to prevent the variables from leaking out
      (function() { 
        console.log('[dev] injecting snowpack entry')
        var snowpackEntryScript = document.createElement('script');
        snowpackEntryScript.setAttribute('type', 'module');
        snowpackEntryScript.setAttribute('src', '/_dist_/index.js');
        document.head.appendChild(snowpackEntryScript);
      }())
    }
  </script>
</body>
If you view the index.html after running a CRA build, you'll see that the script was mostly pruned. It is rendered as <script>0</script>, effectively doing nothing. Not quite as good as "not there at all," but close enough for me.

Conclusion

Is it really a good idea to use a different build system for dev and production? Maybe not, but it sure is fun to try out. I hope the steps above have helped you figure out how to tempt fate and give Snowpack a shot.
Thanks for reading! If you've got any questions, feel free to reach out to me on twitter @pbesh.