· Michał Roman · Tutorials · 6 min read
Refactoring spaghetti React
Taking over an old React project can be daunting. Often you open the codebase and find class components or outdated function components written without hooks, heavy with side effects, and lacking clear boundaries between responsibilities.
Refactoring spaghetti React: Migrating a legacy react app with hooks and TanStack Query
The challenge and setting goals
Taking over an old React project can be daunting. Often you open the codebase and find class components or outdated function components written without hooks, heavy with side effects, and lacking clear boundaries between responsibilities. The result is spaghetti code that works, but is fragile and hard to extend.
Before diving into code, it is worth setting goals. Ask yourself what success looks like. Maybe you want to reduce duplicated API calls, improve error handling, or shorten the time it takes to add new features. It is also important to think about risks. Changing the way data is fetched can affect authentication, caching, or user flows. A simple rollback plan can make the team more confident when changes go live.
With the goals in mind, it helps to establish guardrails. Even if the legacy app has no tests, you can add a minimal safety net with a mock server like MSW and a handful of smoke tests. Strict linting rules and some type coverage around API responses already improve confidence. Running these checks in CI ensures that every commit passes through the same filters.
Defining architecture and choosing migration targets
Once there is a foundation, think about the architecture you are aiming for. Data that comes from the server and can be cached belongs in TanStack Query. Local UI state such as modals, tabs or form inputs stays in component state or context. Side effects like API calls should move out of render paths into custom hooks or service functions. Separating these concerns makes each part easier to understand.
The next step is to map the existing code. Not every part of the app needs to change at once. Identify where prop drilling is worst, where fetches happen directly in the render, and where duplication is causing bugs. Those are good candidates for early migration. Focusing on the most painful spots brings value faster and keeps the migration realistic.
Strangler fig strategy and TanStack Query
A useful strategy here is the so‑called strangler fig pattern. Instead of tearing everything down, you build a new structure around the old one. You can keep legacy API calls running while you introduce a new API client, and you can use feature flags to switch pages one by one to the new approach. In some cases, it even makes sense to run old and new fetch logic in parallel for a short time to confirm that they return the same results. Once the new version works well, delete the old code quickly so you don’t end up maintaining two systems forever.
This is where TanStack Query comes into play. It takes over responsibilities like caching, retries, background refresh, and optimistic updates. Instead of building custom loaders and state flags, you declare the data you want, and the library handles the rest. To keep things organized, define query keys per domain such as users or orders, and wrap your API calls in small helpers. From there, you can build domain-specific hooks like useUsers
or useCreateUser
that your components can consume without worrying about how the data is fetched or updated. The key is to trust the cache, avoid duplicating server state in another global store, and make sure errors surface in a clear way.
Refactoring react and handling UI state
Refactoring the React code to use hooks is best done in small steps. Pull side effects out of render functions and move them into custom hooks. Split components so that containers are responsible for data fetching and pass clean props to presentational components. Avoid writing a tangle of useEffect calls that trigger each other; instead, lean on events or cache invalidation. Optimize only when you see real performance issues, rather than scattering useMemo or useCallback everywhere.
Not all state belongs in TanStack Query. UI details like which modal is open or which tab is selected should stay outside. Forms are better handled by dedicated libraries such as React Hook Form. For global concerns like theme or authentication snapshots, a simple context or a small state library is often enough. A feature‑first folder structure also helps by keeping related code such as hooks, API calls, and components together under one feature folder.
A step-by-step example
To make the migration process concrete, imagine a user list page. The first step is to write a smoke test that confirms the page loads. Then wrap the application with a QueryClientProvider. Replace the old fetch call with a useUsers
hook powered by TanStack Query. Add a useCreateUser
mutation that performs an optimistic update. Remove manual loading and error flags from the component and rely on the query state instead. At first you can hide this new version behind a feature flag and test it in staging. When it works reliably, delete the old implementation.
Performance, testing, and tooling
Performance and reliability improve naturally with TanStack Query, since it deduplicates requests and lets you configure stale times and refresh behavior. You can still fine tune render performance by avoiding heavy prop drilling and by virtualizing long lists when needed. Observing metrics such as cache hit ratio and slow renders helps verify that things are going in the right direction.
Testing also becomes simpler. Hooks can be tested in isolation with mocked API responses. Pages can be tested with a real QueryClient and a mock server. End‑to‑end tests remain valuable for checking full flows, including failures such as timeouts or server errors. In unit and integration tests, you can even seed the cache to skip network calls entirely.
On the tooling side, enforce linting for hooks, add pre‑commit checks, and consider generating typed clients from an OpenAPI or GraphQL schema. For deployment, start with a canary release controlled by feature flags, monitor logs and error trackers, and document how to roll back if needed.
Pitfalls and closing thoughts
There are a few traps worth mentioning. Don’t duplicate server data in another global store, don’t invalidate the cache too aggressively, and don’t hide errors from users or developers. And above all, don’t attempt massive pull requests that change half the codebase at once. Migrating feature by feature keeps the effort manageable.
Bringing order to a messy React codebase is possible without a rewrite. By setting up guardrails, migrating in slices, and relying on TanStack Query to handle server state, you reduce boilerplate and make the system easier to maintain. The process takes time, but each step makes the application a little cleaner and more reliable. Start with one page, prove the approach, and build momentum from there.