Ever try using npm link with React? While it works for simple examples, more often it will break when one starts adding hooks into their application. While things like Storybook are great for developing components in isolation, sometimes it makes sense to locally test components in the application that is consuming them. Lets explore why npm (and yarn) link is broken, and how we can use yalc to fix it
Why?
My favorite way of building component libraries is in a monorepo using yarn workspaces. I recently converted craco-babel-loader to use such a style here. What's nice is that since everything is using a package.json as it would in the wild, there's little "magic" that needs to be done outside of just using workspaces. Unfortunately, not all projects, especially enterprise ones can fall neatly into this bucket. So, conventional wisdom says one should probably use npm link or yarn link to develop a package separate from where it would be used.
Note, throughout this, one should be able to substitute the use of npm link for yarn link. You can use either link command in either package manager and will run into just about the same nuances.
Loving npm link
Let's try it!
I've created the
following repo as an example. It
contains a standard CRA application in the cra-app
folder with my-component
as the stand-in for our component library.
Start by cloning it down, and doing a cd my-component
followed by a
yarn install
, yarn build
, and finally: npm link
. This will create a global
link for what is listed as the name
field in the package.json
file. You
should get a message saying that the package was added.
Now, go into the cra-app
and do a yarn install
followed by
npm link my-component
. This will add a symlink for my-component
in the
node_modules folder of cra-app
. Run npm start
in the cra-app
and the app
should render! There should be text that reads "Hello World" in the browser!
Mission accomplished! Time to go home right!? Let's try just throwing a hook
into MyComponent
now and do a yarn build
in my-component
!
import React from "react";
export const MyComponent = () => {
React.useEffect(() => {
console.log("npm link is my best friend!");
}, []);
return <div>Hello World</div>;
};
...and kaboom! Like any epic tragedy, at the end of the first act so falls the non-titular yet somehow the expected hero of the story.
Hating npm link
You are probably here for two reasons. Either you're researching tooling for
building component libraries, or you've been burned by this guy after running
npm link
:
Error: Invalid hook call. Hooks can only be called inside of the body of a
function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React
DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app See
https://reactjs.org/link/invalid-hook-call for tips about how to debug and
fix this problem.
That's no fun! Let's take a close look at what the error means:
- My package.json has the versions of React and React DOM being the same, so likely not this.
- Pretty sure I'm not breaking the rules of hooks.
- Is there more than one version of React?
That's our only remaining option, right? There's a pretty useful command called
npm ls
that will tell us what is hanging out in our node modules folder.
$ npm ls react
cra-app@0.1.0 /Users/rjerue/dev/yalc-examples/cra-app
├─┬ @testing-library/react@11.2.7
│ └── react@17.0.2 deduped
├─┬ component-lib@1.0.0 extraneous -> ./../component-lib
│ └── react@17.0.2 extraneous
├─┬ react-dom@17.0.2
│ └── react@17.0.2 deduped
├─┬ react-scripts@4.0.3
│ └── react@17.0.2 deduped
└── react@17.0.2
Uh oh! There is indeed two versions of react living inside of our application! There's one inside of our cra app, and there is another in the component library. But why? I have peer dependencies set up correctly and at the very least, they're the same version so it should be deduplicated, right? RIGHT?
Well, if this was treated like a real dependency, yes. However, technically
my-component
isn't even in our package.json, it was added via npm link. Even
if it was in there though, adding it via npm link creates a symlink inside of
node_modules to the root of my-component
. The my-component
directory has its
own node_modules
folder and inside of it... its own version of react.
All npm link
does is just create a symlink, it doesn't rebuild the module tree
to dedupe the version of react.
A fun sanity check is to just delete my-component/node_modules/react
and the
error will go away. Heck, why even include it as a devDependency? Well in the
real world, one likely has tests living alongside their components, and that
will indeed need some version of react to run those tests against. React is also
just the tip of the iceberg. One could also run into problems with things that
require a single instance of a library, such as things that create new react
contexts that both applications would consume the same instance of.
Falling in love with yalc
It is my opinion that npm link
is broken. All it serves as is a fancy wrapper
around ln
. Npm's module resolution algorithm does not care that it is a
symlink. It'll treat the folder like any other application and look for
dependencies in its package before moving a folder upwards. The first instance
of react found by the code in my-component/dist/index.js
is in
my-component/node_modules/react
rather than the node_modules of cra-app. One
solution is to create more symlinks, but this does not really solve other issues
and can lead to big pain. Luckily there's a better way.
The library yalc by "wclr" (a.k.a. "whitecolor"
or "alex") was made to solve this problem. In its own words, it acts as a very
simple local repository for your locally developed packages that you want to
share across your local environment and was made specifically to prevent the
problems created by using npm link
and yarn link
.
In our example, let's install yalc globally with
npm i yalc -g
. One may also instead install it as a dev dependency where
needed and use npx
if one would prefer to avoid global packages. Go into
my-component
and run yalc publish
. One will get something like:
$ yalc publish
component-lib@1.0.0 published in store.
Now go back to cra-app
and run yalc add component-lib
. One should see an
output similar to:
$ yalc add component-lib
Package component-lib@1.0.0 added ==> /Users/rjerue/dev/yalc-examples/cra-app/node_modules/component-lib
From there, running npm start
in cra-app
will yield a working component with
the message that we logged to the console earlier.
Using yalc Effectively
Yalc has some pretty comprehensive documentation on how it can be used. Though, here's the cliff notes:
yalc publish
👉 Writes files that would be in npm registry to a local registryyalc add
👉 Adds the yalc dependency to your project over what is in the package.json. Adds a.yalc
folder with contents and a signature lock file.yalc link
👉 Similar tonpm link
but only links the files that would be in the remote npm. Does not persist betweenyarn install
runs.
One can add the yalc.lock
lock file and .yalc
folder to gitignore. It's not
very useful in git.
My favorite part of yalc is that the publish
command also supports the propagation of updates to where the package has been
added or linked. Try changing the component in component-lib
while CRA is
running and run the following command and notice the accompanying output in the
terminal:
$ yarn build && yalc publish --push
yarn run v1.22.17
$ microbundle --jsx React.createElement
Build "component-lib" to dist:
226 B: index.cjs.gz
171 B: index.cjs.br
173 B: index.modern.js.gz
134 B: index.modern.js.br
181 B: index.module.js.gz
135 B: index.module.js.br
311 B: index.umd.js.gz
245 B: index.umd.js.br
✨ Done in 1.03s.
component-lib@1.0.0 published in store.
Pushing component-lib@1.0.0 in /Users/rjerue/dev/yalc-examples/cra-app
Package component-lib@1.0.0 added ==> /Users/rjerue/dev/yalc-examples/cra-app/node_modules/component-lib
You should see those changes be hot reloaded into the running cra-app
!
Watching For Great DevX
There are also ways that you can add a watch setup for these commands! As the component library is using microbundle to create a single output folder, we can use a tool like nodemon and npm-run-all to automatically publish changes after a build is done!
Start by installing the following packages:
$ yarn add npm-run-all yalc nodemon --dev
and then add the following under scripts
in the package.json
of the
component library:
{
"scripts": {
// ... other scripts
"dev": "microbundle watch --jsx React.createElement",
"watch-dist": "nodemon --delay 1 --watch dist --exec \"yalc publish --push\"",
"start": "run-p dev watch-dist"
}
}
What this does is it runs the microbundle watcher and nodemon in parallel.
Nodemon will watch the dist
folder and do a publish after it is updated by a
new build running.
If using another tool to build the library, just replace dev
with whatever
script you use, and replace dist
with the outputted library with the built
files.
Wrapping Up!
The commands npm link
and yarn link
are the cause of the Invalid hook call
error message because it leads to there being more than one copy of React in the
same app. Yalc gets around this by creating
something similar to a local npm registry and allows for you to treat your local
library like any other as opposed to the link commands that treat them
differently. This allows for one to develop component libraries locally in the
application that is consuming it with all the traditional comforts of dev tools
such as hot reloading!
What sorts of cool things are you going to be building using yalc? 😎
Yalc: https://github.com/wclr/yalc