Building React Apps in 2024: Lessons from Scaling Production Applications
Practical patterns for maintainable React architecture learned from real-world projects
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)
- useState - For simple UI state:
const [isMenuOpen, setIsMenuOpen] = useState(false);
- 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>
);
}
- Redux Toolkit (from July 2024 - I will pick Zustand) - 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>;
Lessons from Production
The 3 AM Incident That Changed My Approach
- 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
}
}
- 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:
- Can this component be reused? → Move to
/components/ui
- Does this state need to be global? → Start local, lift up later
- Are we memoizing expensive operations? → Profile first!
- Is the TypeScript type actually helpful? → Avoid
any
at all costs. Tools like graphQL codegen will save massive time!
Remember: Good architecture is about making changes easy, not about theoretical perfection. 💡