Designing Atomic Modules in a React Monorepo
Stop shipping one mega “shared” package for every surface area in your monorepo. Break functionality into atomic feature modules, wire them together with workspace protocols, and keep each team’s blast radius small.
Spotting the "Kitchen Sink" Package
In a typical monorepo you’ll see packages/my-ui pulling double duty for apps/web, apps/mobile, and apps/admin. Over time it grows god objects, cross-app imports, and circular dependencies. A quick audit usually reveals:
- Hidden coupling: shared components import
../../apps/web/colors.ts, breaking other apps the moment a file moves. - Implicit scope: files like
utils.tsballoon into dozens of unrelated functions. - Refactor paralysis: circular references (
A → B → C → A) make it painful to extract anything.
Restructure Around Features
Instead of one general-purpose package, model each feature or surface area as its own workspace. Keep things small, opinionated, and tagged by domain:
packages/
@transactions/history-table
@transactions/recurring-table
@transactions/add-transfer-modal
@video/player
@video/editor
@video/upload
@ui/button
@ui/modal
Guidelines that keep the architecture predictable:
- Clear entry points: export from
src/index.tsand document required props or providers. - Platform-specific files: use
.native.tsx/.web.tsx(or Expo’s platform resolver) so each package can target mobile and web without branching the consumer app. - No back references: packages never import from
apps/**. If something is shared, promote it to its own package.
Package Layout
Each workspace can be tiny, but a consistent skeleton makes onboarding painless:
packages/@accounts/add-modal/
package.json
src/
components/
hooks/
services/api.ts # network calls only
services/utils.ts # thin helpers wrapping api.ts
constants/colors.ts
index.ts
Packages that only expose a single function or hook can skip the folders—just guard against the "misc" dump by splitting things once they grow.
Managing Dependencies
Workspace protocols keep versions in sync without publishing every package. A typical package.json looks like:
{
"name": "@accounts/add-modal",
"version": "1.0.0",
"main": "src/index.ts",
"dependencies": {
"@transactions/transfer-modal": "workspace:^",
"@ui/form": "workspace:^",
"@ui/modal": "workspace:^"
},
"peerDependencies": {
"react": "^18",
"react-native": "*"
},
"module": "dist/index.js"
}
Use Yarn 4 workspaces (or pnpm/Bun equivalents) to hoist shared deps, and declare peers for runtime libraries so each app decides which version to ship.
Version Consistency
If you pin versions inside packages, add a spec synchronizer like syncpack to avoid drift:
// .syncpackrc
{
"indent": " ",
"versionGroups": [
{
"label": "whole monorepo",
"specifierTypes": ["exact", "range", "workspace-protocol"]
}
]
}
This setup ignores * wildcards (handy for internal React Native packages) while enforcing consistency everywhere else.
Conclusion
- A single massive package shared across projects in a monorepo can become difficult to maintain.
- Create a package per feature with a narrow, documented API surface.
- This conversion doesn't have to happen all at once, you can single out specific features and roll-out the change.
- Atomic Modules significantly reduced the complexity of adding new shared packages for me.