Sitemap
CodeX

Everything connected with Tech & Code. Follow to join our 1M+ monthly readers

Press enter or click to view image in full size
Created by the author free images and figma.

From Legacy to Leading: Modernizing Your Old React Codebase

Modern Frontend Architecture — 102

12 min readAug 21, 2024

--

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

Most of us are stuck with a pile of tech debt.
Some companies are finally waking up and saying:

Here are 10 battle-tested steps to take your old React app from sluggish to sleek in no time.

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 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"]
}

for that JS linting.
And at some point start renaming all of the or files into or respectively. You can and should do this 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 can find their way around without asking a million questions.

This is a

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,
However not that one specific section component you need for the home page only. That is not a general component for me.

Inpagesor modulesare subfolders with so called . Whenever you have a URL that should render a specific group of components (with or without business logic) it’s a module or page.

servicesare an abstraction that take care of business logic and can also be used to interact with API requests using Axios.

configis 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. 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
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:

  • UserService instead of UsrSvc
  • ProductController instead of PrdCtrl

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.

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.

class Home extends Component {
constructor() {
super();
this.state = {
products: [],
loading: true,
};
}
}

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 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.

: Hooks like useEffect roll multiple lifecycle methods into one, slashing the boilerplate and making your code easier to grasp.

: Hooks promote better separation of concerns, making your logic reusable across components without the fuss!

: Class components can easily spiral into a mess of prop drilling, HoCs, and “bracket salad.”
Hooks clean up state and side-effect management.

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 MyComponent extends React.Component {
componentDidMount() {
// Fetch data or set up subscriptions here
}
render() {
return <div>My Component</div>;
}
}

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 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>;
}
}

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 MyComponent extends React.Component {
componentWillUnmount() {
// Cleanup here
}
render() {
return <div>My Component</div>;
}
}

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 MyComponent extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
return nextProps.someValue !== this.props.someValue;
}
render() {
return <div>My Component</div>;
}
}

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.
In React, this translates to keeping your business logic distinct from your UI components.

  • : Hooks can be reused across different parts of the application without duplicating state or business logic.
  • : Logic can be tested independently of the UI.
  • : Easier to read and maintain when UI and logic are decoupled.

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.

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;

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;

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.

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.

  • : Helps new team members understand the codebase faster.
  • : Ensures everyone follows the same patterns and practices.
  • : Captures important decisions and context for future reference.

  • : Add comments to explain why certain decisions were made or to clarify complex logic.
  • : Use TypeScript annotations to describe the purpose of functions, parameters, and return types.
  • : Create a docs directory 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.

  • : Reduces runtime errors by catching type mismatches during development.
  • : Improves developer experience by enabling better IDE support.
  • : Types can serve as documentation for the expected shape of data.

  • Define interfaces or types for your data models.

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.
Team’s that don’t pay for testing, will eventually have to pay differently.

  • : Ensures your application behaves as expected.
  • : Gives you confidence to make changes and refactor code.
  • : Tests can serve as documentation for how your code is supposed to work.

  • : Use Jest or Mocha for testing individual functions and components. React testing library!
  • : Test how different parts of your application work together.
  • : 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.

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.

  • : Explain what the project does and its purpose.
  • : Provide step-by-step instructions on how to set up the project locally.
  • : Include examples of how to build, run and use the application.
  • : 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!

--

--

CodeX
CodeX

Published in CodeX

Everything connected with Tech & Code. Follow to join our 1M+ monthly readers

Responses (1)