Engineering

Keeping a Large React Codebase Navigable

A React codebase becomes difficult when developers cannot predict where behaviour belongs or what a change might affect. Navigability comes from feature boundaries and dependency rules, not a perfect folder diagram.

6 min read
A wireframe plan of a dense grid of nested modules with clear arterial routes running through it, one route highlighted.

Large frontends rarely become confusing because React lacks a pattern. They become confusing because several reasonable patterns accumulate without a rule for choosing among them. A customer page may fetch through one client, store server data globally, duplicate form state locally and import a component that quietly knows about billing. Developers search by filename, follow re-export chains and hesitate to remove code because ownership is unclear. Navigability means a person can start from a route or business capability, find its UI, data access, tests and policies, and predict the permitted dependencies. Achieving that requires architecture expressed in directories, APIs and automated constraints.

Organise around capabilities that change together

Top-level folders containing every component, hook and service look tidy at first but scatter one feature across the repository. Prefer feature or domain slices for substantial product behaviour: orders, billing, identity and reporting. Each slice can contain its routes, components, queries, schemas and tests, exposing a small public entry point. Truly generic UI, infrastructure and utilities belong in clearly named shared layers, but shared should be earned by multiple uses rather than predicted. Feature boundaries make ownership and deletion easier. They also reveal coupling: if billing repeatedly imports order internals, the relationship needs an explicit contract or a reconsidered boundary rather than another relative path.

Make dependency direction enforceable

A folder convention is documentation until tooling rejects violations. Define layers such as application shell, features, shared domain utilities and design system, then state which may import which. Use lint rules, TypeScript project references or package boundaries to prevent deep imports and cycles. Expose deliberate public APIs from features, keeping internal components private. Be cautious with barrel files: they can conceal dependency edges, create cycles and enlarge bundles if tooling cannot eliminate unused exports. Architecture tests should run in continuous integration and produce actionable messages. Exceptions may be necessary, but record them with ownership and a removal condition rather than weakening the rule globally.

Separate server, URL and interaction state

Not all state has the same lifetime or source of truth. Remote records belong in a server-state layer that handles caching, invalidation, loading and errors. Search terms, filters and selected tabs often belong in the URL when users need links, history and refresh persistence. Form drafts belong close to the form, with explicit submission and validation. Short-lived interaction state can remain in a component or focused context. Moving everything into one global store makes ownership obscure and synchronisation manual. Derived values should usually be computed rather than stored. For each state item, document who owns it, what resets it and whether it must survive navigation; those answers suggest the appropriate mechanism.

  • Can a developer find a feature from its route or user-facing name?
  • Does each feature expose a small public API rather than internal paths?
  • Are dependency direction and cycle rules checked automatically?
  • Is remote data distinct from URL, form and transient interface state?
  • Can shared components be understood without knowledge of one feature?

Give components distinct levels of responsibility

A design-system button owns visual variants and accessibility behaviour, not invoice policy. A composed shared component may combine controls into a date range picker or data table with a stable, domain-neutral API. Feature components understand business language and orchestrate queries, permissions and actions. Route components assemble the page and boundary concerns. Keeping these roles distinct prevents generic components from acquiring dozens of flags for unrelated workflows. Prefer composition over a universal component that conditionally renders every case. Component examples and visual tests are useful at shared layers, while feature tests should focus on user outcomes. Accessibility semantics belong at the lowest layer that can enforce them consistently.

Put contracts at network and form boundaries

TypeScript types do not validate data arriving at runtime. Parse API responses and form submissions with schemas, then convert transport shapes into domain-friendly forms where that reduces coupling. Centralise authentication, base URLs, error normalisation and tracing in an HTTP layer, but keep endpoint definitions near their feature owners. Generated clients can reduce drift when the source specification is reliable; generated code should remain isolated from interface components. Distinguish expected domain errors from infrastructure failures so pages can offer meaningful recovery. Mock at the network boundary for most frontend integration tests, using realistic payloads and errors, rather than mocking every hook until the test only confirms its own setup.

Keep change safe through local evidence

A navigable feature keeps tests near the behaviour and has fast checks developers can run while editing. Use unit tests for pure transformations, component tests for interaction and a smaller set of end-to-end journeys for integration confidence. Story or example files help discover visual components, but should not duplicate business tests. Track bundle boundaries and lazy-load by route or substantial feature where it improves initial delivery; indiscriminate splitting adds waterfalls. Delete dead exports and dependencies regularly. Code ownership can route review, yet it should not create silos: public APIs, decision records and readable tests allow another team to work safely when needed.

Naming and search are architecture tools

Use the language visible in the product and domain rather than generic names such as Manager, Helper or Common. A component named PaymentApprovalPanel is discoverable from a support report about payment approval; ProcessPanel is not. Name hooks for the question they answer and commands for the action they perform. Avoid several files called utils.ts containing unrelated exports, because search results become context-free and ownership disappears. Consistent suffixes for route, query, schema and test files can make repository search and editor navigation faster without forcing every feature into an identical internal shape. Routes, analytics events, permission keys and API operations are valuable entry points when investigating behaviour. Keep their definitions close enough to the feature that a search leads to the implementation, and generate or centralise them only where uniqueness requires it. Document non-obvious boundaries in short decision records linked from the relevant directory rather than a remote architecture document nobody updates. New-developer exercises are useful tests of navigability: ask someone to trace a user action, locate its data contract and make a contained change. Friction found during that exercise should improve naming, boundaries or tooling. The best structure is not merely elegant to its authors; it provides practical clues to a reader who does not yet know the history.

Naming and searchability reinforce these boundaries. Use product and domain terms rather than Manager, Helper or Common, and give route, query, schema and test files consistent suffixes where that improves search. Keep permission keys, analytics events and API operations close enough that searching for a user action leads to its implementation. Rename concepts when product language changes instead of preserving several synonyms indefinitely. Short decision records beside a non-obvious boundary are more useful than a remote architecture diagram. Ask a developer unfamiliar with the feature to trace one action from route to contract; their friction is direct evidence about missing clues, ambiguous ownership and unnecessary indirection.

Refactor by establishing one clear path

Do not pause product work for a repository-wide rearrangement. Choose a feature that changes often, define the target boundaries and move it vertically from route through data access. Add dependency checks before migrating more code, so the new structure cannot erode. Create explicit adapters where old and new conventions meet, then remove them as migration progresses. Measure practical signals such as cycles, deep imports, duplicated state and time spent locating ownership. The destination is not identical folders everywhere. It is a codebase where placement follows a small set of understandable rules, dependencies point predictably and each business capability can evolve without requiring a tour of the entire application.

Apply the thinking to your system.

Share the architecture, constraints and decision you are facing. We will respond to the engineering problem in front of you.