Clojurescript with NPM, React and Reagent in 2021

Intro

There is much outdated information floating around on the web regarding the use and integration of npm modules with clojurescript. Some

These documents come up with every web search for "clojurescript npm" etc. and lead to a huge amount of confusion, hurting the progress and adoption of clojurescript and clojure. NPM integration is essential and these approaches are dead ends.

The situation in 2021

Things has become much better, the intgration much easier, with the introduction of packagers like webpack and parcel into the already fantastic clojurescript stack. This guide to webpack integration with clojurescript is well worth reading: https://clojurescript.org/guides/webpack

A Youtube video is an excellent summary of the approach and I wanted to put the gist of this video, the code snippets, on the web as text:

Figwheel Main

Figwheel Main is an indispensible, mature too for clojurescript development. shadow-cljs should have similar facilities but I had all kinds of problems integrating this tool into my workflow.

With Figwheel Main installation, the main issues usually arise from a Jetty version mismatch. So it is advisable to use the Jetty version used by Figwheel Main, currently 9.4.36.v20210114. You should exclude all other Jetty version with a :exclusions statement in your build file. I also use ring-jetty9-adapter for http2 support. This has to be kept at version 0.14.3 for Jetty versions under 10, otherwise you will get compilation errors due to incompatability!

In the figwheel documentation, first read https://figwheel.org/docs/npm.html. Then your create a fighweel config file in the root of your project, named sth. like

dev.cljs.edn

for the "dev" build.

In this file you simply add the figwheel config option like so for example:


^{:auto-bundle :webpack
  :watch-dirs ["src"]
  :css-dirs ["resources/public/css"]
  
  }
  

This setting does a lot. It sets the compile :target to :bundle and puts the correct webpack command in the pipeline as the final step. So the final compilation is not by the closure compiler as usual, but webpack.

Don't forget to update the path of your js include file in your html to the result of this last compilation and bundling, which is usually something like "/cljs-out/dev/main_bundle.js" while with regular figwheel it's sth. like "/cljs-out/dev-main.js" (epending on your config of course).

Node Stuff

Initialize npm in your project folder:


npm install -y

This will do a bunch of stuff, among the create a package.json file. This file we don't have to care about any further. No need to deal with the mess that is node and JavaScript from our beautiful, organized Clojure world!

Then install webpack:


npm add --save-dev webpack webpack-cli

Install the npm package you want to use, for me it was react-chords:


npm install @tombatossals/react-chords

If you use reagent, also install react and react dom here, because cljsjs/react and cljsjs/react-dom will NOT WORK with webpack (see below).


npm install react react-dom

Now there are react and react-dom folders in the node_modules folder. These can be used by webpack.

For some reason, I also had to npm install acorn, no idea why but it wasn't a problem.

In your cljs

In your cljs file, require your npm package, due to the package name in this case it has to be a string, in most cases it's just a symbol just like regular cljs:

(ns your-ns
(:require
[reagent.core :refer [adapt-react-class]]
[reagent.dom :as rdom]

 ["@tombatossals/react-chords/lib/Chord" :as Chord]);;<-- here's your npm, Chord has has the js class/function 
)
   

In node.js the import code is


import Chord from '@tombatossals/react-chords/lib/Chord'

and then the react usage is to just return a

<Chord chord={chord} instrument={instrument} lite={lite}/>

from some function.

In clojurescript I could get a reagent component with this process:

  • Make a require like above, no :refer, just :as.
  • The Chord symbol referenced with :as now contains #js, obviously a JavaScript object wrapped in a little data structure.

So to retrieve it

(def chord (get (js->clj Chord) "default"));; there may be simpler ways to do this

and adapt it for reagent (as with cljsjs components):

(def MyChord
(reagent/adapt-react-class chord)
)

MyChord can now be used as a normal reagent tag:

(rdom/render
[MyChord {:chord chord-config-hash
          :instrument instrument-config-hash
          :lite lite}]
		  (.getElementById js/document "chord_container"))
			 

Advanced compilation for production

In Clojurescript development, the bugbear is advanced compilation. Everything might work fine in development mode, when :optimizations is :none, but things fall apart when :optimizations is :simple or :advanced. With npm integration and react, after a successful compilation and webpack run, you may get cryptic in Chrome dev tools errors like:

main_bundle.js:2 Uncaught TypeError: Cannot read properties of undefined (reading 'unstable_cancelCallback')
    at main_bundle.js:2
    at Object.444 (main_bundle.js:2)
    at __webpack_require__ (main_bundle.js:2)
    at main_bundle.js:2
    at main_bundle.js:2

This error is in a huge single-line document, with all cryptic variable names. Impossible to debug or even to load into emacs.

Again it's advisable to slowly and thoroughly read the npm guide at figwheel.org, especially the "Compiling for production section." Then add :clean-outputs to your figwheel config meta in <your production build>.cljs.edn:

^{:auto-bundle :webpack
  :watch-dirs ["src"]
  :css-dirs ["resources/public/css"]
  :clean-outputs true ;;<-- important!
  }

Solution

The cause for above error is hinted at here: https://clojureverse.org/t/clojurescript-fulcro-and-advanced-compilation/7355 and this discussion might be interesting.

Basically, React is not required correcly by webpack, thus undefined and all vars based on it likewise. This happens when there's cljsjs/react and/or cljsjs/react-dom in the source folder of webpack (default: /target/public/cljs-out/<your build name>).

Apparently, webpack will get confused and use neither this package nor the one at <project_root>/node_modules/react as is preferable and even expected by npm modules that rely on react.

You now have to exclude all references to cljsjs/react and cljsjs/react-dom via :exclusions statements, for leiningen in your project file. It's usually not enough to just use

[reagent "1.1.0" :exclusions [cljsjs/react cljsjs/react-dom]]

(here's how to do this with Clojure CLI: https://clojurescript.org/guides/webpack) as is sometimes suggested in other articles.

It's much better to make the :exclusions at your defproject level that do this (leiningen docs):

"Global exclusions are applied across the board, 
as an alternative to duplication for multiple dependencies with the same excluded libraries."

See the leiningen sample project.clj:


(defproject com.myproj "0.1.0-SNAPSHOT"
  :description "My great project."
  :dependencies [
                 [org.clojure/clojure "1.10.3"]
                 [reagent "1.1.0"]
  ...
  ]
  :exclusions [cljsjs/react cljsjs/react-dom cljsjs/react-dom-server]
  )

In leiningen, you can now run


lein deps :tree

until you're absolutely sure you have no reference to these libraries. You may also check target/public/cljs-out/prod if there is a cljsjs folder with these libs.

Then run your usual build production build command, for lein, long and short:

lein run -m figwheel.main --optimizations advanced --build-once prod 
lein fig -- -O advanced -bo prod

It should work now, at least not throw the above error.

Final thoughts

There you have it, an easy integration of npm and clojurescript, just one line in your figwheel config, just two npm packages to install, a single :exclusions in your project file, no messy :foreign-libs, :npm-deps that never worked, no externs nightmare, no abandoned cljsjs projects. The clojure/clojurescript stack is really approaching perfection and the npm integration is absolutely crucial. It now works very well and will only get better.

Hope it helps.

Posted: 18 October 2021