From Legacy to Leading: Modernizing Your Old React Codebase
Modern Frontend Architecture — 102
Motivation
Three years ago, I shared my blueprint for scalable React Apps, and it clicked with a lot of you.
But let’s be real — most codebases aren’t perfect, and it’s time to face the music. Good codebases are rare diamonds.
Most of us are stuck with a pile of tech debt.
The good news?
Some companies are finally waking up and saying:
“Let’s clean this mess up.”
But how do you do it?
Here are 10 battle-tested steps to take your old React app from sluggish to sleek in no time.
PS: Don’t forget to put up your favorite mix while refactoring…
You are welcome to enjoy mine:
Roger Sanchez b2b Oliver Heldens DJ Set (London) | Ministry of Sound
1. Move to TypeScript.
TypeScript — don’t debate it, just do it. It’s the secret sauce for clean, predictable code.
Interfaces, types and (almost) no magic JS.
Start by adding a (tsconfig.json) file something around that:
{
"compilerOptions": {
"target": "es2022",
"module": "ESNext",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noImplicitAny": false,
"removeComments": false,
"lib": ["dom", "es2023"],
"allowJs": true,
"checkJs": false,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"baseUrl": "./",
"paths": {
"app/*": ["src/*"]
},
"importHelpers": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/","vite.config.ts","types.d.ts"],
"exclude": ["node_modules/", "tests","public", "**/*.spec.ts", "**/*.spec.tsx"]
}Add a .eslintrc.json for that JS linting.
And at some point start renaming all of the .js or .jsx files into .tsx or .ts respectively. You can and should do this STEP by STEP.
There is no need to have it all in TypeScript immediatly.
You can easily have a mix of both at first and gradually start adapting.
2. Create a Clear Folder Structure and Naming.
Your folder structure should be so clear that even a fresh grad can find their way around without asking a million questions.
This is a Bad Example:
src/app/ui/pages/v1/account/setup-config-helper.js
In a folder like components I like to put all UI elements, that are used more than once! A button, a list, an accordion yes. A footer as well. A header, YES.
However not that one specific section component you need for the home page only. That is not a general component for me.
In pages or modules are subfolders with so called views. Whenever you have a URL that should render a specific group of components (with or without business logic) it’s a module or page.
services are an abstraction that take care of business logic and can also be used to interact with API requests using Axios.
config is a folder where we store any kind of configuration files.
Like Axios interceptors, middleware, authentication configuration and our redux store setup (that’s just my preference).
In shared I usually place models (interfaces & types) and reducers.
That’s mainly it. Also please don’t use relative folders. Use aliases like app and use index.ts files to export all the components, assets or services.
Your ideal import should look something like that:
import { Button, List, Accordion, Profile } from "app/components";
import { User, Product } from "app/models";
import { UserService, ProductService } from "app/services";Imagine you’re walking into a library where none of the books are labeled. You’d be lost, right? The same goes for your code.
Clear, descriptive names are the labels of your codebase.
They should tell you, immediatly, what something is and hopefully what it does.
When you name things well, you make it easier to understand the purpose and function of your code.
When you’re working with components, naming isn’t just about clarity — it’s about communicating the intent and need as well.
Let’s say you have a button component. A generic name like Button is fine for a reusable component...but if you have a button that triggers a specific action, be explicit about it:
import { SaveButton, CancelButton } from 'app/components';And please avoid abbreviations and various cryptic names
Names like UsrSvc or PrdCtrl might make sense now, but what about in six or twelve months?
Or for a new developer joining the project? Spell it out:
UserServiceinstead ofUsrSvcProductControllerinstead ofPrdCtrl
3. Top Down Approach
Why go top-down? Well, let’s be real — if you don’t know how your app is built from the ground up, you’re basically blind.
Starting from index.tsx lets you see the big picture, so you know exactly how all the pieces fit together.
I like to setup all my middleware, redux store, axios interceptorsand config stuff there.
While it might be tempting to add configurations and middleware scattered throughout the app, placing these in index.tsx simplifies your app’s architecture.
This centralizes critical setup, making the structure cleaner and easier to follow.
Then, as you move to app.tsx, you can focus on rendering routes and UI components, knowing that the groundwork has been properly laid.
Using solutions like React Router, setting up routes is easy.
A clean, well-understood structure means fewer headaches down the road. As your app grows, you’ll appreciate knowing exactly where to put things, and it’ll make onboarding new developers much smoother too.
4. Move to Vite, Bun or Deno.
Ditch Webpack. It’s 2024, people. We’re in the fast lane now — Vite, Bun, Deno — take your pick. Speed isn’t a luxury; it’s a necessity.
These tools aren’t just fast; they’re lightning. Instant HMR, minimal config, and builds so quick you won’t have time to grab a coffee.
With jsr.io, we’re in the fast lane. The future is about getting things done.
If I can shave off even a second from my build time, I’m all in.
Fast builds, fast feedback — because time is the only thing you can’t get back. So why settle for slow?
Dive into the future of fast builds with Vite, explore the blazing speed of Bun, or experience modern development with Deno.
These tools are built for speed, simplicity, and a smoother developer experience — welcome to the new standard.
5. Use Hooks for Business Logic
Every typical old class component looks something like this:
class Home extends Component {
constructor() {
super();
this.state = {
products: [],
loading: true,
};
}
async componentDidMount() {
// do something
}
async shouldComponentUpdate(nextProps) {
if ((this.props.loggedIn !== nextProps.loggedIn) && nextProps.loggedIn) {
await this.renderProducts(this.props.token)
}
}
render() {
<Home props>
}
}
const mapStateToProps = state => state.authentication;
export default connect(mapStateToProps)(Home);First things first.
Why move to hooks?
Because they’re awesome. Seriously. Say goodbye to prop drilling and lifecycle spaghetti — hooks make your code clean, reusable, and headache-free.
Class components? They’re a maze of props, higher-order components, and tangled code with lifecycle methods pasted all over.
Want to manage state? You’re stuck inside a component, right?
Need to share logic? You’re forced into a HoC, right? Nope, not anymore.
With React 16.8, hooks changed the game. No more convoluted class components — just clean, functional, and reusable code.
Once you go hooks, you’ll never look back.
Example — Before old class Home Component (home.tsx):
class Home extends Component {
constructor() {
super();
this.state = {
products: [],
loading: true,
};
}
}After (using functional approach and hooks) (home.tsx):
interface HomeProps {
something: number;
}
# interface or type, whatever you prefer
type HomeProps = {
something: number;
}
const Home = (props:HomeProps) => {
const [products, setProducts] = useState<Array<Product>>([]);
const [loading, setLoading] = useState<boolean>(true);
}
export default Home;Lifecycle Methods
Back in the early days of React, class components were the go-to for managing state and lifecycle methods.
They let you tap into a component’s lifecycle — mounting, updating, unmounting — all that good stuff. And that was insanely good.
I cam from jQuery okay? I wrote all that DOM manipulation stuff myself…But then React 16.8 dropped, and with it, React Hooks.
Suddenly, handling lifecycle events became way easier and more functional, giving me cleaner, more maintainable code.
The code I wished for.
Simplicity: Hooks like useEffect roll multiple lifecycle methods into one, slashing the boilerplate and making your code easier to grasp.
Reusability: Hooks promote better separation of concerns, making your logic reusable across components without the fuss!
Avoiding Anti-Patterns: Class components can easily spiral into a mess of prop drilling, HoCs, and “bracket salad.”
Hooks clean up state and side-effect management.
How to Replace Lifecycle Methods with Hooks?
Here’s how you can transform each lifecycle method into an equivalent using hooks:
componentDidMount
The componentDidMount method is used to perform actions after the component has been inserted into the DOM, such as fetching data, setting up subscriptions, or triggering animations.
Class Component:
class MyComponent extends React.Component {
componentDidMount() {
// Fetch data or set up subscriptions here
}
render() {
return <div>My Component</div>;
}
}Functional Component with Hooks:
const MyComponent = () => {
useEffect(() => {
// Fetch data or set up subscriptions here
}, []); // Empty dependency array ensures this runs only once, similar to componentDidMount
return <div>My Component</div>;
};componentDidUpdate
The componentDidUpdate method is invoked immediately after updating occurs. This is often used to perform operations based on prop or state changes.
Class Component:
class MyComponent extends React.Component {
componentDidUpdate(prevProps) {
if (this.props.someValue !== prevProps.someValue) {
// Perform an action based on prop change
}
}
render() {
return <div>My Component</div>;
}
}Functional Component with Hooks:
const MyComponent = ({ someValue }) => {
useEffect(() => {
// Perform an action based on prop change
}, [someValue]); // Dependency array ensures this runs when someValue change
return <div>My Component</div>;
};componentWillUnmount
The componentWillUnmount method is used to perform any necessary cleanup, such as invalidating timers, canceling network requests, or cleaning up subscriptions.
Class Component:
class MyComponent extends React.Component {
componentWillUnmount() {
// Cleanup here
}
render() {
return <div>My Component</div>;
}
}Functional Component with Hooks:
const MyComponent = () => {
useEffect(() => {
return () => {
// Cleanup code here
};
}, []); // Empty dependency array ensures this runs on mount and unmount
return <div>My Component</div>;
};shouldComponentUpdate
The shouldComponentUpdate method allows you to prevent unnecessary renders by returning false when the component's output does not depend on changes in props or state.
Class Component:
class MyComponent extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
return nextProps.someValue !== this.props.someValue;
}
render() {
return <div>My Component</div>;
}
}Functional Component with Hooks:
React does not have a direct equivalent for shouldComponentUpdate in functional components, but you can achieve similar behavior by memoizing the component with useMemo:
const MyComponent = useMemo(() => {
return <div>My Component</div>;
}, [someValue]);6. Separate Business Logic from View / UI
A core principle in modern engineering architecture is separation of concerns. I do me and you do you.
In React, this translates to keeping your business logic distinct from your UI components.
Why?
- Reusability: Hooks can be reused across different parts of the application without duplicating state or business logic.
- Testability: Logic can be tested independently of the UI.
- Maintainability: Easier to read and maintain when UI and logic are decoupled.
How?
Start by moving your business logic (e.g., data fetching, state management and even UI callbacks) into custom hooks.
Your UI components should focus solely on rendering the data.
Example - Before (Mixed Logic and UI) (product.tsx):
const ProductList = () => {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchProducts() {
const response = await fetch('/api/products');
const data = await response.json();
setProducts(data);
setLoading(false);
}
fetchProducts();
}, []);
if (loading) return <div>Loading...</div>;
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
};
export default ProductList;After (Separated Logic):
Custom Hook (useProducts.ts):
import { useState, useEffect } from 'react';
export interface UseProductsResult {
state: { products: Product[], loading: boolean }
actions: { () => void }
}
const useProducts = ():UseProductsResult => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
async function fetchProducts() {
const response = await fetch('/api/products');
const data = await response.json();
setProducts(data);
setLoading(false);
}
fetchProducts();
}, []);
const doSomething = ():void => {
}
return { state: {products, loading}, actions: { doSomething } };
};
export default useProducts;UI Component (productList.tsx):
import useProducts, { UseProductsResult } from './hooks/useProducts';
const ProductList = () => {
const { state, actions }:UseProductsResult = useProducts();
const { products, loading } = state;
const { doSomething } = actions;
if (loading) return <div>Loading...</div>;
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
};
export default ProductList;7. Add Documentation
Nobody likes to do it…but documentation is truly essential for maintaining a codebase that is understandable and accessible to other developers, including your future self. Junior developers will thank you too.
I always say: Keep the documentation in the repo as much as you can. This way anyone who works on the code, has an easy time to update the Readme.md or other .md files as well.
In this article you will find more details about how to create a good frontend documentation.
Why?
- Onboarding: Helps new team members understand the codebase faster.
- Consistency: Ensures everyone follows the same patterns and practices.
- Knowledge Sharing: Captures important decisions and context for future reference.
How?
- Inline Comments: Add comments to explain why certain decisions were made or to clarify complex logic.
- Docstrings: Use TypeScript annotations to describe the purpose of functions, parameters, and return types.
- External Documentation: Create a
docsdirectory in your project where you can elaborate on architecture decisions, coding standards, and best practices. Use markdown for formatting.
JSDoc Example:
/**
* Fetches the list of products from the API.
* @returns {Promise<Product[]>} The list of products.
*/
async function fetchProducts(): Promise<Product[]> {
const response = await fetch('/api/products');
return await response.json();
}8. Add Interfaces/Types
TypeScript adds a robust type system to JavaScript, helping catch errors early in the development process and making your code more predictable and easier to understand.
Why?
- Type Safety: Reduces runtime errors by catching type mismatches during development.
- Auto-Completion: Improves developer experience by enabling better IDE support.
- Documentation: Types can serve as documentation for the expected shape of data.
How?
- Define interfaces or types for your data models.
Example (product.model.ts)
interface Product {
id: number;
name: string;
price: number;
description?: string;
}9. Write Tests
I have no illusions. Tests are expensive, nobody likes to do them and there is never ever time for it. But testing is critical to ensure your application works as expected and to prevent regressions as the codebase evolves.
So what can I tell you?
Team’s that don’t pay for testing, will eventually have to pay differently.
Here’s the why again…
- Reliability: Ensures your application behaves as expected.
- Confidence: Gives you confidence to make changes and refactor code.
- Documentation: Tests can serve as documentation for how your code is supposed to work.
But how?
- Unit Tests: Use Jest or Mocha for testing individual functions and components. React testing library!
- Integration Tests: Test how different parts of your application work together.
- End-to-End Tests: Use tools like Cypress or Playwright to simulate user interactions and test the entire application flow.
Here is a good guide on how to setup frontend testing.
Example — Simple Jest Test (productList.spec.tsx):
import { render, screen } from '@testing-library/react';
import ProductList from './ProductList';
test('renders loading state', () => {
render(<ProductList />);
const loadingElement = screen.getByText(/loading/i);
expect(loadingElement).toBeInTheDocument();
});10. Provide a Clear README.md
Your README.md is the first thing anyone sees — make it count.
It’s your project’s face. Make it beautiful.
It should give an overview of the project, how to get started, and any important details.
Oh you are too busy to craft one? No problem. You will need to spend time with your first hire explaining all the little fun things…like how to build the project, run it, what things to watch out for etc. Fun right?
Make any new teammate go through the README and update it. This will save you so much trouble and time in the long run.
How?
- Project Overview: Explain what the project does and its purpose.
- Getting Started: Provide step-by-step instructions on how to set up the project locally.
- Usage: Include examples of how to build, run and use the application.
- Contributing: Outline how others can contribute to the project.
Final Thoughts
Modernizing an old React app might seem challenging, but with the right approach, it can be both manageable and rewarding.
By embracing TypeScript, organizing your folder structure, switching to faster build tools like Vite, and leveraging the power of hooks, you’re setting yourself up for a cleaner, more maintainable codebase.
Remember to keep your logic separate from your UI, document your work, and add tests where you can — your team will thank you for it!
The tech world moves fast, but with these steps, you’ll not only keep up — you’ll stay ahead. So roll up your sleeves, put on your favorite playlist, and get ready to transform that legacy code into something you can be proud of. Welcome to the modern era of React development!

