Back to Blogs
ReactArchitectureTypeScriptPerformance

Building React Apps in 2024: Lessons from Scaling Production Applications

Practical patterns for maintainable React architecture learned from real-world projects

5 mins read

When I first inherited a large React codebase four years ago, I felt like I was navigating a maze of components. Through refactoring pains and performance battles, here's what I've learned about structuring React apps that don't collapse under their own weight.

Component Organization That Actually Works

The Folder Structure That Saved My Sanity

/src
  /features  # Product features
  /components
    /ui      # Generic UI pieces
    /layout   # App-wide layouts
  /lib       # Utilities/config
  /providers # Context providers
  /types     # TypeScript definitions

This structure helped me avoid the "components dumpster" problem. Here's how we use it:

// features/dashboard/components/MetricCard.tsx
// Feature-specific components stay with their feature

// components/ui/Button.tsx
// Reusable across the entire app
export function Button({ children }: { children: React.ReactNode }) {
  return <button className="...">{children}</button>;
}

State Management: Choosing Your Weapons

When to Use What (From My Mistakes)

  1. useState - For simple UI state:
const [isMenuOpen, setIsMenuOpen] = useState(false);
  1. Context - For app-wide settings:
// lib/theme-context.tsx
const ThemeContext = createContext<"light" | "dark">("light");

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme] = useSystemTheme(); // Some custom hook
  return (
    <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
  );
}
  1. Redux Toolkit - For complex app state:
// features/cart/cartSlice.ts
const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] as CartItem[] },
  reducers: {
    addItem: (state, action: PayloadAction<CartItem>) => {
      state.items.push(action.payload);
    },
  },
});

Performance Tricks That Actually Matter

The Memoization Sweet Spot

// components/product/ProductList.tsx
const ProductList = memo(({ products }: { products: Product[] }) => {
  return products.map((product) => (
    <ProductCard key={product.id} product={product} />
  ));
});

// Only re-render when products array changes

Lazy Loading Done Right

// routes/main.tsx
const CheckoutPage = lazy(() => import("../features/checkout/CheckoutPage"));

<Suspense fallback={<Spinner />}>
  <Routes>
    <Route path="/checkout" element={<CheckoutPage />} />
  </Routes>
</Suspense>;

Testing Strategy That Scales

The Testing Pyramid I Actually Use

// components/__tests__/Button.test.tsx
test("calls onClick when clicked", () => {
  const handleClick = vi.fn();
  render(<Button onClick={handleClick}>Click me</Button>);
  fireEvent.click(screen.getByText(/click me/i));
  expect(handleClick).toHaveBeenCalled();
});

// features/checkout/__tests__/payment.test.ts
test("handles payment failure", async () => {
  mockPaymentAPI.mockRejectedValue(new Error("Payment failed"));
  render(<CheckoutPage />);

  await userEvent.click(screen.getByText(/pay now/i));
  expect(await screen.findByText(/payment failed/i)).toBeInTheDocument();
});

Lessons from Production

The 3 AM Incident That Changed My Approach

  1. Always validate API responses:
// lib/api.ts
export function isApiError(response: unknown): response is APIError {
  return (
    typeof response === "object" && response !== null && "errorCode" in response
  );
}

async function fetchData() {
  try {
    const response = await axios.get("/api/data");
    if (isApiError(response.data)) {
      throw new Error(response.data.message);
    }
    return response.data;
  } catch (err) {
    // Handle error
  }
}
  1. Implement proper error boundaries:
// components/ErrorBoundary.tsx
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <FallbackUI />;
    }
    return this.props.children;
  }
}

Architecture Checklist I Wish I Had

When starting a new project, I now ask:

  1. Can this component be reused? → Move to /components/ui
  2. Does this state need to be global? → Start local, lift up later
  3. Are we memoizing expensive operations? → Profile first!
  4. Is the TypeScript type actually helpful? → Avoid any at all costs

Remember: Good architecture is about making changes easy, not about theoretical perfection. 💡