A package's version numbers aren't just maintenance metadata. They're a record of decisions — when something was locked, when it was trusted to float, and what the team considered stable enough to stop thinking about.
Most people scan a package.json for what's installed and move on. But the version ranges — caret, tilde, exact, npm alias — carry as much information as the package names themselves. Reading them carefully on a project the size of React gives you a case study in how a production team at scale manages dependency risk over years.
→ Open React's package.json in ilovejson — search, diff, tree view ↗
Before reading individual entries, it helps to be clear about what each range means in practice inside a monorepo build tool context — which is different from what it means in a shipped library.
| Range | Example | Meaning in a build tool |
|---|---|---|
| ^ caret | ^7.11.1 | Accept patch and minor updates. Lock the major. |
| exact | 22.1.0 | Trust nothing. Lock the exact commit. |
| npm alias | npm:pkg@^2 | Install under a different name — usually for parallel versions. |
React uses almost exclusively caret ranges. There are only two exact pins in the entire file, and both are deliberate. Let's look at what that reveals.
"eslint-plugin-jest": "28.4.0" ← exact, no caret
"jest": "^29.4.2" ← caret, allowed to float
Jest itself floats. But eslint-plugin-jest is exact-pinned at 28.4.0. This combination is intentional. The ESLint plugin for Jest validates test patterns — things like ensuring expect is called correctly, that it.only isn't accidentally committed, and that async tests don't have unhandled rejections. A minor version bump in the plugin might start flagging patterns that React's test suite uses deliberately. By pinning the plugin exactly while letting Jest float, the team decoupled lint stability from test runner stability.
When you see an exact pin on an ESLint plugin but a caret on the runner it covers, the message is: we trust this tool to evolve but we've made a specific decision about its rules and we're not accepting that decision changing automatically.
"resolutions": {
"react-is": "npm:react-is",
"jsdom": "22.1.0" ← exact resolution override
}
The resolutions field is yarn's mechanism for forcing a specific version of a transitive dependency regardless of what the package requesting it declares. The exact pin on jsdom@22.1.0 here is the most revealing line in the entire file.
jsdom is the simulated browser environment that Jest uses for DOM-related tests. Later versions of jsdom changed how they implement certain browser APIs — specifically around CSS and some edge cases in event dispatching. React's test suite relies on specific jsdom behaviour that changed in versions after 22.1.0. Rather than update hundreds of tests to match new jsdom semantics, the team pinned the resolution and moved on.
This is not a failure. It's the correct call for a project where the test suite is the specification. But it does create a hidden debt: at some point jsdom's behaviour will need to be reconciled, and the pin will need to move. Every day it stays there, the delta grows slightly larger.
"prettier": "^3.3.3"
"prettier-2": "npm:prettier@^2" ← v2 aliased separately
Installing two versions of Prettier via npm aliases is a pattern that appears in large codebases during a major version migration. Prettier 3 introduced breaking formatting changes from Prettier 2 — output for certain JSX patterns and long strings differs between versions. Running both allows the team to format different files with different versions, or to compare output during the migration, without creating a dependency conflict.
The prettier-plugin-hermes-parser entry (version 0.36.1) is the connector: it makes Prettier use the Hermes parser for formatting JavaScript files rather than Prettier's built-in parser. This matters because Hermes supports some Facebook-internal syntax extensions and has different error recovery behaviour. For React source files that contain Flow types, using Hermes's parser for formatting avoids situations where Prettier's own parser chokes on valid Flow syntax.
All 28 Babel packages in the file use caret ranges, but the base versions are not uniform:
| Package | Version | Base |
|---|---|---|
@babel/core | ^7.11.1 | 7.11 — mid-2020 |
@babel/parser | ^7.11.3 | 7.11 — mid-2020 |
@babel/plugin-syntax-jsx | ^7.23.3 | 7.23 — late 2023 |
@babel/plugin-transform-react-jsx | ^7.23.4 | 7.23 — late 2023 |
@babel/preset-typescript | ^7.26.0 | 7.26 — late 2024 |
The core and parser are anchored to versions from 2020. The JSX transform plugins were updated in late 2023 — almost certainly to pick up the new JSX transform that React 17 introduced. The TypeScript preset is anchored to a 2024 version, reflecting when TypeScript definitions became a first-class concern. You can read the dependency update history of a project directly from these base version numbers without ever looking at a git log.
art — the Ghost Dependency"art": "0.10.1" ← exact pin, not updated since ~2017
art is a 2D drawing library — the underlying renderer for react-art, React's canvas/SVG renderer. It's pinned exactly at 0.10.1, which hasn't changed in years. React-art is still in the repo but is rarely discussed in the context of modern React. Its presence here is a reminder that React's monorepo contains historical experiments that never fully graduated, alongside the core packages. The exact pin signals the team has decided to freeze it rather than update it.
The ESLint entries form a coherent picture of how large organisations govern code quality:
"eslint": "^7.7.0" ← ESLint v7, from 2020
"eslint-plugin-react-internal": "link:./scripts/eslint-rules"
"eslint-plugin-react-hooks-published": "npm:eslint-plugin-react-hooks@^5.2.0"
Three things stand out. First, ESLint v7 as the base. ESLint is now at v9 with a completely different flat config system. React is on the legacy config. This is not unusual for a project where upgrading the linter requires auditing every rule file, every plugin interaction, and every CI configuration. The caret means they'll accept ESLint 7.x patches but not the v8 or v9 jump.
Second, eslint-plugin-react-internal using the link: protocol — pointing to a local directory inside the repo. This is a set of custom ESLint rules written specifically for React's codebase: rules that check for patterns specific to how React uses closures, flags, and internal invariants. These rules don't exist publicly and can't be installed from npm. They're part of the repo's institutional knowledge.
Third, eslint-plugin-react-hooks-published aliased to the public npm version of the hooks plugin. The React team maintains the hooks lint rules, and they install their own public plugin as a devDependency to validate it against the source. The alias name (-published) distinguishes it from any internal version being developed in the same repo.
The habits visible in React's manifest are transferable to any project:
Anchor your base versions intentionally. React's Babel core at ^7.11 is not laziness — it's a stable baseline that the team trusts. The caret means they'll take patches but they know what the floor is. Don't update base versions passively in batch renovations without understanding what changed at that major boundary.
Exact pins are a statement, not a shortcut. The two exact pins in React's file — eslint-plugin-jest and jsdom in resolutions — both carry a specific reason. If you're exact-pinning because you're worried about breaking changes without reading changelogs, that's a different thing entirely.
npm aliases are a migration tool. The two-version Prettier setup is a controlled migration pattern. When you upgrade a major version of a formatting or linting tool, running both during a transition period is cleaner than a big-bang switch. The alias makes it explicit that both are intentional.
Local plugins encode organisational knowledge. The link: ESLint plugin isn't vanity — it's the only way to enforce patterns that are specific enough to one codebase that they'll never make sense as a published package. If your team has recurring review comments about the same patterns, a local lint rule is the right answer, not a comment template.
link: to find local dependencies, search for packages without ^ to find exact pins. The structure becomes navigable in seconds rather than minutes. Try it with React's file here →
The raw file is public. Switch to Tree view in any JSON viewer, search for link: to find local plugins, search for packages without a caret to find exact pins. The diff view makes comparing two versions of the same package.json — say, React 18 vs React 19 — straightforward. Load it directly →