I am Ryan Jerue, a Software Engineer working remotely at Amazon from Saratoga Springs, New York. My focus is in creating scalable web systems. I can be contacted by email over ryan at jerue dot org.

Using yalc Instead Of npm Or yarn link To Develop React Components

11/28/2021

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

Click to copy
folder with my-component
Click to copy
as the stand-in for our component library.

Start by cloning it down, and doing a cd my-component

Click to copy
followed by a yarn install
Click to copy
, yarn build
Click to copy
, and finally: npm link
Click to copy
. This will create a global link for what is listed as the name
Click to copy
field in the package.json
Click to copy
file. You should get a message saying that the package was added.

Now, go into the cra-app

Click to copy
and do a yarn install
Click to copy
followed by npm link my-component
Click to copy
. This will add a symlink for my-component
Click to copy
in the node_modules folder of cra-app
Click to copy
. Run npm start
Click to copy
in the cra-app
Click to copy
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

Click to copy
now and do a yarn build
Click to copy
in my-component
Click to copy
!

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

Click to copy
:

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:

  1. My package.json has the versions of React and React DOM being the same, so likely not this.
  2. Pretty sure I'm not breaking the rules of hooks.
  3. Is there more than one version of React?

That's our only remaining option, right? There's a pretty useful command called npm ls

Click to copy
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

Click to copy
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
Click to copy
. The my-component
Click to copy
directory has its own node_modules
Click to copy
folder and inside of it... its own version of react.

All npm link

Click to copy
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

Click to copy
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

Click to copy
is broken. All it serves as is a fancy wrapper around ln
Click to copy
. 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
Click to copy
is in my-component/node_modules/react
Click to copy
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

Click to copy
and yarn link
Click to copy
.

In our example, let's install yalc globally with npm i yalc -g

Click to copy
. One may also instead install it as a dev dependency where needed and use npx
Click to copy
if one would prefer to avoid global packages. Go into my-component
Click to copy
and run yalc publish
Click to copy
. One will get something like:

$ yalc publish
component-lib@1.0.0 published in store.

Now go back to cra-app

Click to copy
and run yalc add component-lib
Click to copy
. 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

Click to copy
in cra-app
Click to copy
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
    Click to copy
    👉 Writes files that would be in npm registry to a local registry
  • yalc add
    Click to copy
    👉 Adds the yalc dependency to your project over what is in the package.json. Adds a .yalc
    Click to copy
    folder with contents and a signature lock file.
  • yalc link
    Click to copy
    👉 Similar to npm link
    Click to copy
    but only links the files that would be in the remote npm. Does not persist between yarn install
    Click to copy
    runs.

One can add the yalc.lock

Click to copy
lock file and .yalc
Click to copy
folder to gitignore. It's not very useful in git.

My favorite part of yalc is that the publish

Click to copy
command also supports the propagation of updates to where the package has been added or linked. Try changing the component in component-lib
Click to copy
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

Click to copy
!

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

Click to copy
in the package.json
Click to copy
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

Click to copy
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

Click to copy
with whatever script you use, and replace dist
Click to copy
with the outputted library with the built files.

Wrapping Up!

The commands npm link

Click to copy
and yarn link
Click to copy
are the cause of the Invalid hook call
Click to copy
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