Managing Dependencies in a React Project
Understand npm and package.json, how lock files pin the exact dependency tree, semantic versioning, the difference between direct and transitive dependencies, how to update dependencies safely, and how to audit for vulnerabilities while keeping the dependency footprint minimal.
Every React project you build leans on a mountain of code you did not write: React itself, a bundler, maybe a router, a data-fetching library, testing tools. npm is the package manager that downloads, tracks, and links that code together. Getting comfortable with package.json, lock files, semantic versioning, and dependency auditing is not optional busywork — it is how you keep a project buildable a year from now, avoid pulling in malicious code, and prevent 'works on my machine' bugs caused by two developers silently running different versions of the same library.
npm and package.json
npm (Node Package Manager) is the default package manager for the JavaScript/TypeScript ecosystem and ships with Node.js. It reads a manifest file called package.json that lists your project's dependencies, scripts, and metadata. Running npm install reads that manifest, resolves every dependency (and every dependency's dependencies), downloads the packages into a node_modules folder, and records the exact resolved tree in a lock file.
package.json separates dependencies into a few buckets. dependencies are packages your app needs at runtime, such as react and react-dom. devDependencies are packages needed only during development or build, such as typescript, eslint, or a test runner — they are not shipped in a production bundle but are required to build and verify the app. peerDependencies declare a package that the consumer of your library must provide themselves (common in component libraries that expect the host app to supply react). Version ranges in package.json use semver operators: a caret ^18.2.0 allows updates that do not change the leftmost non-zero digit, a tilde ~18.2.0 allows only patch-level updates, and an exact string 18.2.0 pins one version precisely.
• npm install <pkg> adds to dependencies and installs it • npm install -D <pkg> (or --save-dev) adds to devDependencies • npm uninstall <pkg> removes a package and updates package.json • npm ci installs strictly from the lock file — used in CI for reproducible, fast installs • npm run <script> executes a script defined in the scripts block of package.json
Lock files and reproducible installs
package.json describes acceptable version ranges, not exact versions. Two installs run on different days, or by different developers, could legally satisfy ^18.2.0 with different actual versions if a new compatible release has shipped in between. The lock file — package-lock.json for npm, yarn.lock for Yarn, pnpm-lock.yaml for pnpm — freezes the exact resolved version (and often the exact hash) of every package in the tree, direct and transitive. It is what makes a build reproducible: the same lock file installed on any machine produces the identical node_modules tree, byte for byte in terms of resolved versions.
The lock file must be committed to version control. It is not an artifact to .gitignore. In CI and in teammates' clones, npm ci reads the lock file, verifies it matches package.json, deletes any existing node_modules, and installs exactly what the lock file specifies — failing loudly if the lock file is out of sync, rather than silently re-resolving versions like npm install would. This is why professional pipelines use npm ci, not npm install, as the build step.
• Lock files should always be committed alongside package.json • npm ci = clean, deterministic install for CI/CD and fresh clones • npm install may update the lock file if ranges allow a newer match — appropriate for local dev, not CI • Never hand-edit a lock file; let the package manager regenerate it
Semantic versioning (semver)
Semantic versioning gives version numbers a shared meaning: MAJOR.MINOR.PATCH, e.g. 18.2.0. A PATCH bump (18.2.0 → 18.2.1) is a backwards-compatible bug fix with no API changes. A MINOR bump (18.2.0 → 18.3.0) adds backwards-compatible functionality — new features, but nothing existing should break. A MAJOR bump (18.2.0 → 19.0.0) signals breaking changes — code written against 18.x may not work unchanged against 19.x. This convention is what makes caret ranges (^18.2.0, meaning 'anything compatible up to but not including 19.0.0') a reasonable default: you get bug fixes and new features automatically, but a major version requires you to deliberately opt in.
Semver is a convention, not something npm enforces on the package author. A library can technically ship a breaking change in a patch release by mistake — this is a real, if uncommon, source of production incidents, and part of why lock files and CI test suites matter: they catch a broken transitive update before it reaches users.
Direct vs. transitive dependencies
A direct dependency is something you listed explicitly in package.json — react, axios, your test runner. A transitive dependency is something one of your direct dependencies depends on, which you never chose and often never even see by name. A typical React app with a handful of direct dependencies can easily pull in several hundred transitive packages. This is normal, but it also means your actual attack surface and bundle-size cost is much larger than package.json alone suggests — run npm ls or inspect node_modules to see the real tree.
Transitive dependencies matter for security and size. A vulnerability three levels deep in the tree still runs in your app's process, and a bloated transitive dependency still ships in your bundle if it's not tree-shaken out. Tools like npm ls <package> show you which direct dependency pulled in a given transitive one, which is essential when deciding how to fix a reported vulnerability that you don't directly control.
Updating dependencies safely
npm outdated lists installed packages next to their current, wanted (the latest version matching your semver range), and latest (the newest version published at all, regardless of range) versions. npm update bumps packages within the ranges already declared in package.json — safe, incremental. Jumping a major version (e.g. React 17 to 18) is a deliberate, separate task: read the package's migration guide, upgrade one major version at a time, run the full test suite, and manually smoke-test the app before merging.
A disciplined update cadence — small, frequent updates rather than letting dependencies rot for a year and then facing a giant, risky jump — keeps each update low-risk. Tools like Dependabot or Renovate can open automated pull requests for dependency bumps, running your CI suite against each one so a human only needs to review green PRs.
• npm outdated — see what's behind • npm update — bump within existing semver ranges • npm install pkg@latest — force to the newest version, ignoring the current range • Upgrade major versions one at a time, reading changelogs, with tests green before and after
Auditing for vulnerabilities and supply-chain security
npm audit checks your installed dependency tree against a database of known vulnerabilities and reports severity (low, moderate, high, critical) along with the vulnerable package, the path to it, and whether a fix is available. npm audit fix attempts to automatically bump vulnerable packages to a patched version that still satisfies your semver ranges; npm audit fix --force will make breaking changes to fix a vulnerability and should be used carefully, reviewing the diff afterward.
Supply-chain security is the broader concern: because npm installs and often executes arbitrary code from strangers (postinstall scripts, the package code itself), a compromised or malicious package — whether uploaded intentionally by an attacker or injected into a legitimate maintainer's account — can run inside your build or your users' browsers. Mitigations include pinning exact versions for security-sensitive packages, reviewing new dependencies before adding them (does it have many users, recent maintenance, a reasonable maintainer history?), running npm audit in CI so a new vulnerability fails the build, and considering tools that restrict install-time scripts.
• npm audit — report known vulnerabilities in the resolved tree • npm audit fix — auto-upgrade to patched versions within semver range • npm audit --production — audit only runtime dependencies (skip devDependencies) • Review a new package's popularity, maintenance activity, and install scripts before adding it • Run audits in CI, not just locally, so a newly-disclosed vulnerability is caught automatically
The minimal dependency principle
Every dependency you add is a liability: it enlarges your attack surface, it can break your build when it updates, it adds to bundle size, and it is code you did not write and may not fully understand. The minimal dependency principle says: before adding a package, ask whether the problem can be solved with a small amount of your own code, or with something already in your toolchain. A left-pad-style utility library for one function you use twice is rarely worth the transitive risk; a well-maintained, widely-used library for something genuinely hard (date parsing, cryptography, virtualization) usually is.
In practice this means periodically pruning unused dependencies (npm prune, or tools like depcheck that find packages listed in package.json but never imported), preferring one well-scoped library over three overlapping ones, and being skeptical of dependencies with a single maintainer and low download counts for anything security- or infrastructure-critical.
Core npm commands
npm ci deletes node_modules first and fails if package.json and the lock file disagree — it never silently re-resolves. Prefer npm ci in CI/CD pipelines and Docker builds; use npm install for everyday local development.
# install everything from package.json (may update the lock file)
npm install
# clean, deterministic install from the lock file — use in CI
npm ci
# add a runtime dependency
npm install axios
# add a dev-only dependency
npm install -D vitest
# remove a dependency
npm uninstall lodash
# see outdated packages
npm outdated
# check for known vulnerabilities
npm audit
# attempt to auto-fix vulnerabilities within semver ranges
npm audit fix
# see why a transitive package is installed
npm ls axiosCode Examples
React and react-dom are direct runtime dependencies the shipped app needs; typescript, vite, eslint, and vitest are devDependencies used only to build and verify the project and are excluded from the production bundle. Each version uses a caret range, allowing safe minor and patch upgrades. The caret means npm will install the highest version that is still 18.x — for example 18.9.4 if that's the latest published — but will never silently jump to 19.0.0, which could break the app.
"react": "^18.2.0" The vulnerable package (semver) is not a direct dependency — it's pulled in transitively by eslint. npm ls semver shows exactly which direct dependency is responsible, which tells you whether you can fix it yourself (as here, via npm audit fix) or whether you need to wait for the upstream maintainer (eslint) to publish an update.
{
"name": "sampleapp-web",
"version": "1.4.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext .ts,.tsx",
"test": "vitest run",
"audit:ci": "npm audit --audit-level=high"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"vite": "^5.1.0",
"eslint": "^8.57.0",
"vitest": "^1.3.0"
}
}$ npm audit
# found 1 high severity vulnerability in "semver" (transitive, via eslint)
# Path: sampleapp-web > eslint > ... > semver
# Fix available via `npm audit fix`
$ npm ls semver
sampleapp-web@1.4.0
└─┬ eslint@8.57.0
└── semver@6.3.0 # vulnerable range
$ npm audit fix
# updated semver to 6.3.1 within eslint's own allowed range
$ npm audit
# found 0 vulnerabilitiesImportant Rules
Always commit the lock file (package-lock.json / yarn.lock / pnpm-lock.yaml) alongside package.json.
Use npm ci in CI and Docker builds; use npm install only for local development.
Treat a major version bump as a deliberate task with its own testing pass, never an accidental side effect of a range that's too loose.
Run npm audit (or an equivalent) in CI so newly-disclosed vulnerabilities fail the build automatically.
Before adding a new dependency, check whether the problem is small enough to solve without one.
Why this matters in production
In 2021, a widely-used but tiny formatting-utility package was found to contain malicious code injected via a compromised maintainer account, and it was transitively pulled into thousands of projects that had never heard of it directly. Teams that pinned dependencies, ran npm audit in CI, and kept their dependency count minimal caught and contained the issue far faster than teams that didn't know what was in their own node_modules tree.
Deleting package-lock.json because it 'causes merge conflicts'.
Resolve lock file conflicts by re-running npm install after resolving package.json conflicts, and commit the regenerated lock file.
Without a lock file, every install can resolve a different transitive dependency tree, reintroducing the exact reproducibility problem lock files exist to solve.
Running npm install as the install step in a CI pipeline.
Use npm ci in CI so installs are deterministic and fail fast if the lock file is stale.
npm install can update the lock file mid-pipeline based on whatever is newest at that moment, so the exact same commit could build successfully today and fail tomorrow.
What is the primary purpose of a lock file (e.g. package-lock.json)? A. To list which packages are devDependencies versus dependencies B. To freeze the exact resolved version of every package (direct and transitive) so installs are reproducible C. To store the source code of installed packages so they don't need to be downloaded again D. To document which scripts are available to run
B. To freeze the exact resolved version of every package (direct and transitive) so installs are reproducible — The lock file pins the exact resolved dependency tree, ensuring that installing on any machine or at any time produces an identical node_modules tree.
In semantic versioning, what does a MAJOR version bump indicate? A. A backwards-compatible bug fix B. A backwards-compatible new feature C. A breaking change to the public API D. A change to devDependencies only
C. A breaking change to the public API — MAJOR bumps (the first number in MAJOR.MINOR.PATCH) signal that the change is not guaranteed to be backwards compatible.
Why should npm ci be preferred over npm install in a CI/CD pipeline? A. npm ci is faster to type B. npm ci installs strictly from the lock file and fails if it's out of sync, giving deterministic builds C. npm ci automatically fixes security vulnerabilities D. npm ci skips devDependencies to save time
B. npm ci installs strictly from the lock file and fails if it's out of sync, giving deterministic builds — npm ci guarantees the exact same dependency tree every time by installing only what the lock file specifies, rather than potentially re-resolving versions.
A transitive dependency is best described as: A. A package listed directly in your package.json B. A package that one of your direct dependencies depends on, which you did not explicitly choose C. A package only used in production builds D. A deprecated package that should be removed
B. A package that one of your direct dependencies depends on, which you did not explicitly choose — Transitive dependencies come along with your direct dependencies' own dependency trees, often numbering in the hundreds even for a small direct dependency list.
Take an existing package.json, run npm outdated and npm audit, identify one direct dependency that is more than one major version behind, and write out the steps you would take to upgrade it safely (including what you'd check before and after).
1) npm outdated to confirm the current vs. latest version. 2) Read the migration guide for breaking changes. 3) npm install package@latest on a dedicated branch. 4) Fix any breakages the type checker or test suite surfaces. 5) Run npm audit again to confirm no new vulnerabilities were introduced. 6) Manually smoke-test critical flows. 7) Open a PR with the changelog linked for reviewers.
Start with the package's changelog/migration guide, upgrade in isolation on a branch, run the full test suite, then manually verify the app in the browser before merging.