Ryan Jerue

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

November 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.

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.

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:

  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 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 registry
  • yalc 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 to npm link but only links the files that would be in the remote npm. Does not persist between yarn 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