Full Tutorial on Updating Dependencies in JS Projects
If you haven't experienced a white-hot fever while updating packages to the latest versions, when each of them depends on the other, you don't know what it's like to be a poor web developer writing in JS. There are tools like NX and others that make things a bit easier. However, upgrading app libraries still requires a lot of caution, planning, strategy, and a solid amount of knowledge to avoid unnecessary pain.
Today, I'll share how I handle it. I'll use a bit of NX, but this article is actually a guide for anyone who's dealing with dependencies. All you have to do is keep reading. In today's entry, we're going to have some fun with upgrading packages, and I'll try to cover everything you need so you'll never have to worry about version mismatches again. Enjoy the tech debt fight!
Adding Backup
Don't be a fool, make a backup... And repeat this step every time you make any "tangible" progress. Do it in the form of commits or even stash your changes. Without this, one mistake—literally just one—and you might have to start all over again.
git add .
git commit -m "step 1: updated react to 18.2.0"
git push
git add .
git commit -m "step 2: fixed context api and ts errors after react bump"
git push
// ...etc
Understanding SemVer
Before you blindly bump versions, you gotta understand what those numbers mean. Semantic Versioning, or SemVer, is the standard most packages follow: MAJOR.MINOR.PATCH
(e.g., 18.2.0
).
- PATCH (0.0.X): Bugs and small things. Usually safe to update, low risk of breaking things.
- MINOR (0.X.0): New features added, but should be backward-compatible. Mostly safe, but keep an eye out—sometimes minor changes can have unexpected side effects or subtle regressions.
- MAJOR (X.0.0): This is the scary one. 😱 It means there are breaking changes. Updating a major version will likely require code changes on your end. This is where you absolutely need to read the changelogs and migration guides.
Pay attention to symbols like ^
(caret) and ~
(tilde) in your package.json
—they carry important meaning 👌:
Symbol | Example | Meaning | Allows Updates To | Will Not Update To |
---|---|---|---|---|
^ | ^16.8.0 | Minor + patch updates | 16.8.1 , 16.9.0 , ...etc | 17.0.0 and above |
~ | ~16.8.0 | Patch updates only | 16.8.1 , 16.8.2 , ...etc | 16.9.0 and above |
(none) | 16.8.0 | Exact version only | 16.8.0 | Anything else |
Understanding these symbols helps you anticipate what npm
or yarn
might pull in during update. For major updates, it's best to bump versions manually (more on that later).
Dependency Types in package.json
Your package.json
splits dependencies into categories—knowing them matters:
dependencies
: Core packages needed in production (e.g. React, Axios). Break these, break your live app.devDependencies
: Dev-only tools (e.g. Jest, Webpack, ESLint). Won’t affect the live app unless your build fails. Safer to update—mostly.peerDependencies
: These are packages that your project needs to work, but instead of installing them itself, it expects the project using your package to have them already installed. For example, a React component library will list React as a peer dependency because it relies on React being present in the app that uses the library. If there’s a version mismatch, you’ll likely encounter errors during installation, especially with modern package managers.optionalDependencies
: Installed if possible, ignored if not. Rarely used...
When updating, handle dependencies
and peerDependencies
with extra care—they’re the riskiest (we'll tackle it in details later).
The Role of Lock Files
Ever heard the classic "But it works on my machine!"? Lock files (package-lock.json
for npm, yarn.lock
for Yarn, pnpm-lock.yaml
for pnpm) are designed to kill that excuse.
When you install packages, the package manager figures out the exact versions of all dependencies (including dependencies of dependencies, and so on) that satisfy the version ranges in your package.json
. It then records these exact versions in the lock file.
Why is this crucial?
- Reproducible Builds: Anyone else on your team (or your CI/CD pipeline) running
npm install
will get the exact same versions of every single package, thanks to the lock file. No surprises. - Consistency: Prevents situations where a minor update to a sub-dependency suddenly breaks your app because different developers have slightly different versions installed locally.
- Tracking: It provides a definitive record of what was actually installed.
Rule #1: Always commit your lock file to version control (Git).
Rule #2: Don't manually edit the lock file unless you really know what you're doing (which usually means you don't need to). Let the package manager handle it when you add, remove, or update packages. During dependency updates, changes to the lock file are expected and necessary.
Node and Package Manager Compatibility
Sometimes the problem isn't the package you're updating, but the tools you're using! Packages often specify the required Node.js version (and sometimes Npm/Yarn version) they need to function correctly. You might find this in the package's README.md
or its package.json
under the engines
field:
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
If you're trying to update a package that requires Node.js 18, but you're still running Node.js 16, you're gonna have a bad time. 😬 Similarly, older versions of Npm/Yarn might not support features used by newer packages or might handle dependency resolution differently (especially regarding peer dependencies). Getting your environment aligned first can save you a world of pain chasing weird installation or runtime errors.
That's why tools like Nvm are so useful—they let you easily switch between different Node versions right from the command line. This can save you a lot of time, especially when managing dependency updates.
Nvm in action
If you try to install or run this package with a lower version, it could fail in different ways depending on the tools you’re using.
- By default, npm doesn't enforce the
engines
field. It might just print a warning. - Yarn is stricter—it can block the install if your Node/npm versions don’t meet the required range.
- Other tools like
pnpm
orvolta
may enforce or respect this field more tightly.
Be Atomic—In This Case, It's a Benefit
If you need to update Next
from 12.x.x
to the latest 15.x.x
, just don't do it all at once! 😆 Every team working on community-driven projects provides changelogs, allowing you to assess the risks of migration. Below is an example from the NX team:
Let's say I used npm-check-updates to detect outdated packages in my project by running the ncu
command. First, you need to install it with:
npm install -g npm-check-updates
Here's what I got after running the command:
That's a lot to update! 😬 This is why you need an iterative mindset, updating packages gradually with each sprint. You can use automated checks or tools like Dependabot from GitHub. But let’s assume we need to update these packages manually anyway, since we’ve spent the whole month working on features...
By saying be atomic at the beginning of this section, I mean updating small groups of packages per PR or commit, based on your strategy. In my case, I’d start with minor/patch updates, like firebase or @xyflow/react. I can do this easily using ncu
with:
ncu -u firebase @xyflow/react && npm install
Of course, you can check changelogs beforehand—or just try it. While SemVer dictates that minor updates should be backward-compatible and often are, it's wise to remain cautious. Subtle regressions or unexpected interactions with other parts of your system can still occur. Always perform basic checks or run your test suite even after minor version bumps.
While tools like Perplexity can offer quick summaries for comparing versions, always treat the official changelogs and migration guides as the definitive source. AI summaries can miss critical breaking changes or subtle nuances. Use them as a starting point, but always verify against the package's documentation, especially for major updates.
Updating Major Packages
Here is an interesting group of packages:
As you can see, react
and react-dom
are likely related packages. Updating both to the same major version is essential for app stability and predictable results. However, doing so may cause the react-markdown package—supporting React 18 in its changelog—to fail. So, it needs updating too. Next, we have the following candidates:
But we've missed something important and tricky here :p. In the earlier listing (the initial one with all libraries), we had other React-related packages. What if they don't support React 18 yet? We need to check their changelogs. Without doing that, there's a high risk of breaking things and wasting time.
In these cases, we should update to the latest possible backward-compatible versions of each package and wait for other dependencies we're using to support React 18. Replacing dependencies with something else is also an option, but that's quite risky...
When performing major updates, you may need to update the syntax in certain parts of your application. Always check the official migration guides for breaking changes—having a TypeScript project is a huge advantage here. 😄
Often, the creators of libraries give you some time to adapt to these changes. They typically mark features as deprecated and release bug fixes in minor versions, allowing you time to migrate. This is done to avoid causing you pain during the migration process later on.
Installation Warnings and legacy-peer-deps
Let’s say you ran the installation command (previously without issues), but when you try to update the packages to the new major versions, you encounter a strange problem.
Errors from Hell
The installation script informs you: "Hey, you have Cypress installed as a package in version 14.2.0, but the other package you've installed expects Cypress version 11.1.2." Library authors specify compatible peer dependency versions by providing a version range that their code supports (see the example below).
"peerDependencies": {
// A package.json from library
"react": ">=16.8.0 < 19.0.0",
"react-dom": ">=16.8.0 < 19.0.0"
}
Modern versions of npm (v7 and later) enforce peer dependencies more strictly by default. During installation, npm attempts to find versions of all dependencies that satisfy every peer requirement specified. If it cannot build a valid dependency tree that meets these conflicting requirements (like one package needing Cypress 14 and another needing Cypress 11), the installation will fail outright with errors like those shown.
Using the --legacy-peer-deps
flag essentially tells npm to revert to older behavior and ignore these peer dependency conflicts during installation, installing them anyway. This can get your project running but carries significant risk of runtime errors or unexpected behavior because the packages weren't designed to work with the installed peer versions. It should only be used as a temporary workaround while you investigate the proper fix, which is usually:
- Wait for the dependent package(s) to update their peer dependencies to compatible versions.
- Find an alternative package that supports your required version.
- Use package resolutions/overrides if absolutely necessary (see later section).
Dealing with Security Vulnerabilities
Ah, security. That thing everyone talks about but hopes won't bite them. Your dependencies can (and often do) have known security vulnerabilities. Ignoring them is like leaving your front door wide open.
Commands like npm audit
, yarn audit
, or services like GitHub's Dependabot security alerts exist for this reason (there are plenty of them). Running npm audit
after an install or update will generate a report like this:
# npm audit report
lodash <4.17.12
Severity: Critical
Arbitrary Code Execution - https://npmjs.com/advisories/1065
fix available via `npm audit fix --force`
Will install react-scripts@2.1.8, which is a breaking change
node_modules/lodash
5 vulnerabilities (3 moderate, 2 critical)
To address all issues (including breaking changes), run:
npm audit fix --force
Try npm audit fix
. This command attempts to automatically update vulnerable packages to safe versions without breaking things (respecting SemVer). Sometimes, it works flawlessly!
If npm audit fix
doesn’t work or suggests a breaking change (via --force
flag), you’ll need to investigate. Check the vulnerability report (the linked URL) and the package's changelog. You might need to update a parent dependency that’s pulling in the vulnerable one, or sometimes, evaluate and accept the risk if it's not exploitable in your specific context (use caution!). It’s all about research, reading, trying, and potentially manual work.
Don’t ignore these warnings. While some might be low-risk, others could be serious (especially for server-side JavaScript or packages handling sensitive data).
Automation to the Rescue
If you have any kind of regression automation, you're in a comfortable position. This is because, at each step, you can run checks to ensure everything is passing. For my projects, I primarily use setups similar to these:
Without automation, every step requires manual confirmation. So, if you have automation, take one step, run the checks, and proceed if everything passes. If you don't have automation and regression checks—you're in trouble. Good luck with the manual work! 😆
Under 4markdown repository, you can check the implementation of these checks.
Follow Migration Guides
This is why I love tools like angular-cli
or NX
. They usually allow me to perform the migration effortlessly by running a single command: migrate
. This command bumps packages to the stable version and automatically changes the code—like replacing outdated syntax with the new one. Most of the time, it works perfectly, but when it doesn't, some manual work is required. 😅
NX Users Have Some Luck
What's really helpful about NX, especially in monorepos, is that if you're using NX plugins (e.g., @nx/react
, @nx/next
), the supported versions of technologies are often managed collectively. These plugins are typically kept in sync and maintained to work with the nx migrate
command. This can streamline the update process considerably.
While this automation is powerful, it doesn't completely absolve you from understanding the underlying changes in React, Next, etc. Breaking changes in the core libraries might still require code adjustments even if nx migrate
handles the dependency bumps and basic syntax updates.
Here's how it generally looks in practice with NX:
- You're using relevant NX plugins.
- A new version of NX (and potentially underlying tech it supports) is released.
- Run
nx migrate latest
(or target a specific version). - It analyzes dependencies and generates necessary updates to
package.json
and potentially code modifications (migrations). - Review the proposed changes.
- Run
npm install
(or yarn/pnpm) to install updated packages. - Run
nx migrate --run-migrations
to apply any automated code mods.
- Run
- Review and test thoroughly!
While
nx migrate
often handles most of the heavy lifting for packages managed by its plugins, be prepared for potential manual adjustments, especially in complex scenarios or when dealing with non-NX-plugin dependencies.
Removing Unused Dependencies
Over time, projects tend to accumulate cruft. You install a library for an experiment, then forget about it. You refactor code and remove the need for a package, but forget to uninstall it. This leads to:
- Bloated
node_modules
: Longer install times, more disk space used. - Increased attack surface: More dependencies mean more potential vulnerabilities.
- Confusion: New team members might wonder why a seemingly unused package is there.
Keep your project tidy! Tools like Depcheck can help identify dependencies listed in package.json
that aren't actually imported anywhere in your code.
npx depcheck
It will list unused dependencies, missing dependencies (used in code but not in package.json
), and sometimes unused devDependencies. Review the list carefully (sometimes dynamic imports or config files can trick these tools) and then uninstall the truly unused ones:
npm uninstall package-name-1 package-name-2
# or yarn remove / pnpm remove
Do this periodically, perhaps before starting a big dependency update session. It's good hygiene and reduces the number of things you need to worry about updating.
Other Gotchas
Even when you do everything right, things can still go wrong. Here are a few other gremlins that might pop up:
- Cache Issues: Sometimes your package manager's cache gets corrupted or holds onto old versions. If installs are failing oddly, try clearing the cache (
npm cache clean --force
,yarn cache clean
, or deletenode_modules
and the lock file if necessary, then reinstall). - Type Definitions (
@types/...
): If you're using TypeScript, remember to update the corresponding type definition packages (@types/react
,@types/lodash
, etc.) when updating the main package. Mismatched types can cause TS errors. You may also need to uninstall@types
packages if the main library now includes its own types. Always check the changelogs! - Build Tool Config: Major updates (especially frameworks like Next.js or tools like Webpack/Babel) may require changes to your build configuration files (
next.config.js
,webpack.config.js
,.babelrc
, etc.). Be sure to check the migration guides! - Platform Differences: Some packages might behave differently or have installation issues depending on whether you're on Windows vs. macOS/Linux. If you're working across different OSes, consider using Docker to ensure consistency in running scripts.
- Implicit Dependencies: A package you use might rely on another package without explicitly listing it as a peer dependency. If an update breaks that implicit relationship, things can fail in unexpected ways. This is rare but can be tricky.
Basically, stay vigilant and expect the unexpected. Murphy's Law definitely applies to dependency updates.
Package Resolutions: The Last-Pick Option
When all other options fail and packages stubbornly demand incompatible versions of shared dependencies, you'll encounter frustrating warnings like:
Conflict: package-a@2.0 requires library@^1.0.0
package-b@3.0 requires library@^2.0.0
Package resolutions (called overrides
in npm/pnpm, resolutions
in Yarn) allow you to force your package manager to use a specific version of a transitive dependency, regardless of what the intermediate packages request.
Package resolutions should only be used when:
- No compatible package versions exist.
- Waiting for updates isn't feasible.
- The conflict blocks critical development.
So, you can do one of the following in your package.json
:
// Yarn
"resolutions": { "problematic-library": "2.1.0" }
// npm / pnpm
"overrides": { "problematic-library": "2.1.0" }
// Limit the scope to reduce risk (npm/pnpm example)
"overrides": {
"package-a": {
"problematic-library": "2.1.0"
}
}
But remember, never use resolutions for:
- Core framework dependencies (like React itself).
- Security-related packages.
- Major version jumps (e.g., forcing v1 users to use v3).
Resolutions are emergency tools, not permanent solutions. They come with technical debt that must be repaid through proper fixes as soon as possible.
Some Tricks and Interesting Commands
Okay, we've covered the big strategies and concepts, but sometimes you need a few quick maneuvers in the trenches. Here are some handy commands and tricks that can save you time and headaches.
Find Out Why a Package is Installed
Ever wonder why that obscure package fancy-utility@1.2.3
is lurking in your node_modules
? Maybe it's a dependency of a dependency (of a dependency...). These commands trace the lineage:
# NPM
npm ls <package-name>
# Yarn Classic (v1)
yarn why <package-name>
# Yarn Berry (v2+) / Pnpm
pnpm why <package-name>
# or yarn why <package-name>
This is super useful for debugging dependency conflicts or figuring out which top-level package is bringing in something unexpected or problematic.
Check for Outdated Packages (Built-in)
While ncu
is awesome, your package manager has a built-in way to peek at outdated packages:
npm outdated
yarn outdated
pnpm outdated
It gives you a quick overview of what could be updated, showing the current, wanted (based on SemVer range in package.json
), and latest versions. It's less powerful than ncu
for interactive updates but great for a quick check-up.
Inspect Package Details Remotely
Need to quickly check the available versions of a package, its peer dependencies, or other metadata without installing it? npm view
(or yarn info
/ pnpm info
) is your friend:
# See all available versions
npm view react versions --json
# Check peer dependencies of a specific version
npm view react-router-dom@6.10.0 peerDependencies
# Get all package info
yarn info <package-name>
pnpm info <package-name>
This helps you research before committing to an update.
The Clean Slate: Nuke node_modules
Ah, the classic "have you tried turning it off and on again?" for JS dependencies. Sometimes, things just get weird—caches get corrupted, installs go sideways. The nuclear option often works wonders:
# 1. Delete node_modules
rm -rf node_modules
# 2. Optional: Clear cache (if you suspect deeper cache issues)
npm cache clean --force
# yarn cache clean (often managed per-project, check Yarn docs for specifics)
# pnpm store prune (cleans the global pnpm content-addressable store)
# 3. Optional (Use with Extreme Caution!): Delete the lock file
# (rm package-lock.json / rm yarn.lock / rm pnpm-lock.yaml)
# Warning: Deleting the lock file forces the package manager to resolve
# dependencies based *only* on the version ranges in your `package.json`.
# This means you might get newer versions than were previously locked,
# potentially introducing unexpected or breaking changes. Only do this
# if you specifically suspect the lock file is corrupted or if you
# *intentionally* want to update dependencies according to `package.json` ranges.
# 4. Reinstall everything cleanly
npm install
# yarn install
# pnpm install
# (If you didn't delete the lock file, this command will reinstall
# based on the *existing* lock file, which is usually the safest way
# to get a clean node_modules folder consistent with your last commit.)
CI Installs: Faster and Safer
In your Continuous Integration pipeline (like GitHub Actions, GitLab CI, Jenkins), you want installs to be fast and perfectly reproducible based on your committed lock file. Don't use npm install
! Use the dedicated CI command:
npm ci
yarn install --frozen-lockfile
pnpm install --frozen-lockfile
Why?
- Faster: It skips some checks and dependency tree negotiations meant for development workflows, directly installing from the lock file.
- Safer: It installs exactly what's specified in the lock file. If
package.json
and the lock file have somehow become out of sync,npm ci
(and the--frozen-lockfile
flags) will throw an error instead of potentially modifying the lock file, ensuring your build uses precisely the committed dependencies.
Summary and Thoughts
So, there you have it. Updating JavaScript dependencies doesn't have to feel like wrestling a greased pig in the dark, but it does demand respect. Let's recap the battle plan:
- Backup: Commit often. Your past self will thank you.
- Understand the Battlefield: Know SemVer, dependency types, and the crucial role of lock files. Test even minor updates.
- Check Your Gear: Ensure Node.js and package manager versions are compatible.
- Be Atomic: Update small, related groups of packages at a time. Minors first, then tackle Majors with caution.
- Read the Intel: Official changelogs and migration guides are your best friends for major updates. Don't rely solely on summaries.
- Listen for Warnings: Pay attention to installation errors (peer deps!) and security audits (
npm audit
). Understand why flags like--legacy-peer-deps
or--force
are needed before using them. - Automate Your Defenses: Linting, type checking, unit tests, E2E tests—run them after each step!
- Leverage Allies: Tools like
ncu
, Dependabot, NX (nx migrate
), and migration scripts can significantly ease the pain, but understand their limits. - Clean Up: Remove unused dependencies periodically (
depcheck
). - Last Resort: Use resolutions/overrides only when absolutely necessary, document them, and track them as tech debt.
It's a process, sometimes tedious, sometimes terrifying, but staying relatively up-to-date is far less painful than facing a multi-year backlog of updates. Keep chipping away at it, use the tools and strategies available, and maybe, just maybe, you can avoid that white-hot fever next time. Good luck out there!
Typical JavaScript project