As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
When I build web applications that need to grow, I think of them like cities. A small town can manage with a few simple roads, but a metropolis needs systems—districts, public transport, zoning laws. My JavaScript components are the buildings in that city. Without good architecture, everything becomes a tangled mess, impossible to navigate or expand. Over the years, working on applications with thousands of components, I've learned that clarity and maintainability don't happen by accident. They come from deliberate patterns. I want to share some of the most effective techniques I use to keep my component architecture clean, even as it scales to a massive size.
It often starts with a simple separation of concerns. I like to think about components in two primary roles: those that handle how things look, and those that manage logic and data. The first type, often called presentational or "dumb" components, are my favorite to write. They are predictable. They receive data and callbacks exclusively through props and focus solely on rendering that information. They have no internal state related to the application's business logic. This makes them incredibly easy to test and reuse.
Here’s a straightforward example. A Button component shouldn't know about shopping carts or user profiles. It should just know about being clicked.
// A presentational Button component
const Button = ({ onClick, children, variant = 'primary', disabled = false }) => {
const baseStyles = 'px-4 py-2 rounded font-medium transition';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-red-600 text-white hover:bg-red-700'
};
return (
<button
onClick={onClick}
disabled={disabled}
className={`${baseStyles} ${variants[variant]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{children}
</button>
);
};
The other type of component, the container or "smart" component, is where the brains live. This component manages state, talks to APIs, handles event logic, and then passes the necessary data down to presentational components. This separation means I can change how data is fetched without touching my UI, and I can redesign my UI without breaking my data logic.
Let's say I have a user profile page. The container fetches the data.
// A container component for a UserProfile
import { useState, useEffect } from 'react';
import Button from './Button'; // Our presentational button
import UserProfileCard from './UserProfileCard'; // Another presentational component
const UserProfileContainer = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
const handleSave = async (updatedUserData) => {
// Logic to save the user back to the API
};
if (loading) return <p>Loading profile...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<UserProfileCard user={user} />
<Button onClick={() => handleSave(user)} variant="primary">
Save Changes
</Button>
</div>
);
};
This pattern is powerful, but sometimes I need to add common functionality—like logging, authentication checks, or data fetching—to many different components. Writing the same logic in every container is tedious. This is where Higher-Order Components (HOCs) used to be the standard solution. An HOC is a function that takes a component and returns a new, enhanced component.
Imagine I need to track when certain components are viewed for analytics. Instead of adding analytics code to every component's useEffect, I can create an HOC.
// A higher-order component to add analytics tracking
const withAnalyticsTracking = (WrappedComponent, componentName) => {
// This returns a new, anonymous component
return function AnalyticsTracker(props) {
useEffect(() => {
// Simulate sending a view event
console.log(`Analytics: ${componentName} viewed`);
// In reality, you'd call an analytics API here
}, []);
// Render the original component with all its props
return <WrappedComponent {...props} />;
};
};
// How to use it
const UserProfileCard = ({ user }) => <div>{/* Card UI */}</div>;
const TrackedUserProfileCard = withAnalyticsTracking(UserProfileCard, 'UserProfileCard');
// Now <TrackedUserProfileCard> has analytics baked in
While HOCs are useful, the React community has largely moved towards two more flexible patterns for sharing behavior: Render Props and Custom Hooks. The render prop pattern involves a component that uses a function as its child (or a prop) to determine what to render. This function receives state and logic from the parent component.
A classic use case is sharing data-fetching logic. I can create a DataFetcher component that handles the loading, error, and success states.
class DataFetcher extends React.Component {
state = { data: null, loading: true, error: null };
async componentDidMount() {
try {
const response = await fetch(this.props.url);
const data = await response.json();
this.setState({ data, loading: false });
} catch (error) {
this.setState({ error: error.message, loading: false });
}
}
render() {
// Call the function passed as a prop, giving it our state
return this.props.children(this.state);
}
}
// Using the DataFetcher
const ProductDisplay = ({ productId }) => (
<DataFetcher url={`/api/products/${productId}`}>
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <ErrorMessage text={error} />;
return <ProductDetails product={data} />;
}}
</DataFetcher>
);
The real game-changer for me, however, has been Custom Hooks. Introduced in React 16.8, hooks let me extract and reuse stateful logic from a component. This is often cleaner than HOCs or render props. A custom hook is just a JavaScript function whose name starts with "use" and can call other hooks.
Let's take that data-fetching logic and turn it into a reusable hook.
// A custom hook for fetching data
import { useState, useEffect } from 'react';
function useFetch(url) {
const [state, setState] = useState({
data: null,
loading: true,
error: null
});
useEffect(() => {
setState({ data: null, loading: true, error: null });
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
})
.then((data) => setState({ data, loading: false, error: null }))
.catch((err) => {
if (err.name === 'AbortError') return; // Ignore abort errors
setState({ data: null, loading: false, error: err.message });
});
// Cleanup function to abort the fetch if the component unmounts
return () => controller.abort();
}, [url]); // Re-run the effect if the URL changes
return state; // Returns { data, loading, error }
}
// Using the hook in a component is now incredibly simple
const ProductDisplay = ({ productId }) => {
const { data: product, loading, error } = useFetch(`/api/products/${productId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage text={error} />;
return <ProductDetails product={product} />;
};
This is much more direct. I can use useFetch in any component, and it's immediately clear what it does. Custom hooks can manage all sorts of complex logic. Another common pain point is form state. Let's build a hook for that.
// A custom hook for managing form state
import { useState } from 'react';
function useForm(initialValues = {}) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const handleChange = (event) => {
const { name, value, type, checked } = event.target;
const finalValue = type === 'checkbox' ? checked : value;
setValues({
...values,
[name]: finalValue,
});
// Clear error when user starts typing
if (errors[name]) {
setErrors({
...errors,
[name]: null,
});
}
};
const handleBlur = (event) => {
const { name } = event.target;
setTouched({
...touched,
[name]: true,
});
// You could add validation here
};
const setFieldValue = (name, value) => {
setValues({
...values,
[name]: value,
});
};
const resetForm = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return {
values,
errors,
touched,
handleChange,
handleBlur,
setFieldValue,
resetForm,
// A helper to get props for a specific field
getFieldProps: (name) => ({
name,
value: values[name] || '',
onChange: handleChange,
onBlur: handleBlur,
}),
};
}
// Usage in a form component
const SignupForm = () => {
const { values, handleChange, handleBlur, resetForm } = useForm({
email: '',
password: '',
agreeToTerms: false,
});
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitting:', values);
// Submit to API
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Email"
/>
<input
type="password"
name="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Password"
/>
<label>
<input
type="checkbox"
name="agreeToTerms"
checked={values.agreeToTerms}
onChange={handleChange}
/>
I agree to the terms
</label>
<button type="submit">Sign Up</button>
<button type="button" onClick={resetForm}>
Reset
</button>
</form>
);
};
As my component tree grows deeper, passing props down through many levels—a problem called "prop drilling"—becomes a headache. If a great-grandchild component needs a piece of data, I have to pass it through every level in between, even if those middle components don't need it. React's Context API is the solution. It lets me create a global state that any component in the tree can access.
I create a context for a theme (like light or dark mode).
// 1. Create the context
import React, { createContext, useState, useContext } from 'react';
const ThemeContext = createContext();
// 2. Create a provider component
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
// 3. Create a custom hook for easy consumption
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
// 4. Wrap your app with the provider
// In App.js:
// <ThemeProvider>
// <MyApp />
// </ThemeProvider>
// 5. Use the theme anywhere, at any depth
const ThemedButton = () => {
const { theme, toggleTheme } = useTheme();
const style = {
backgroundColor: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#000' : '#fff',
padding: '10px',
border: '1px solid #ccc',
};
return (
<button style={style} onClick={toggleTheme}>
Toggle Theme (Current: {theme})
</button>
);
};
One caution with Context: when the value provided changes, every component that consumes that context will re-render. For performance in large apps, I often split contexts into smaller, more focused pieces (like a ThemeContext, a UserContext, and an AppConfigContext), so a change in theme doesn't force a re-render of user-related components.
Performance becomes critical with thousands of components. A common issue is components re-rendering when they don't need to. React.memo is a higher-order component that memoizes a functional component. It will only re-render if its props change.
const ExpensiveListItem = React.memo(function ExpensiveListItem({ item, onSelect }) {
console.log('Rendering item:', item.id); // This will only log when props change
// Some expensive calculation or rendering here
return (
<div onClick={() => onSelect(item.id)}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
);
});
// This component will not re-render unless `item` or `onSelect` changes,
// even if its parent re-renders.
Similarly, the useMemo and useCallback hooks help prevent unnecessary recalculations and function recreations.
import React, { useState, useMemo, useCallback } from 'react';
const ProductList = ({ products, filterText }) => {
// useMemo: Memoize a computed value. Only recalculate if `products` or `filterText` changes.
const filteredProducts = useMemo(() => {
console.log('Filtering products...');
return products.filter(product =>
product.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [products, filterText]);
// useCallback: Memoize a function. Only recreate if `products` changes.
const handleBulkUpdate = useCallback((newPrice) => {
const updatedProducts = products.map(p => ({ ...p, price: newPrice }));
// Send update to API
console.log('Updating all products:', updatedProducts);
}, [products]); // Dependency array
return (
<div>
<p>Showing {filteredProducts.length} products.</p>
{/* `handleBulkUpdate` has a stable identity, so child components using it won't re-render unnecessarily */}
<BulkActionToolbar onUpdateAll={handleBulkUpdate} />
{filteredProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
};
For rendering very long lists, like a table with 10,000 rows, rendering everything at once will freeze the browser. A technique called "windowing" or "virtualization" solves this. It only renders the items that are currently visible in the viewport. Libraries like react-window make this easy, but understanding the concept is key.
Here's a very simplified version of the idea.
import { useState, useRef, useEffect } from 'react';
const VirtualizedList = ({ items, itemHeight, renderItem }) => {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const viewportHeight = 400; // Fixed height for the list container
// Calculate which items to render
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
items.length - 1,
Math.floor((scrollTop + viewportHeight) / itemHeight)
);
const visibleItems = items.slice(startIndex, endIndex + 1);
const handleScroll = (e) => {
setScrollTop(e.currentTarget.scrollTop);
};
// Calculate total list height for the scrollbar
const totalHeight = items.length * itemHeight;
return (
<div
ref={containerRef}
style={{ height: `${viewportHeight}px`, overflowY: 'auto' }}
onScroll={handleScroll}
>
<div style={{ height: `${totalHeight}px`, position: 'relative' }}>
{visibleItems.map((item, index) => {
const actualIndex = startIndex + index;
return (
<div
key={item.id}
style={{
position: 'absolute',
top: `${actualIndex * itemHeight}px`,
height: `${itemHeight}px`,
width: '100%',
}}
>
{renderItem(item)}
</div>
);
})}
</div>
</div>
);
};
// Usage
const BigList = () => {
const allItems = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
return (
<VirtualizedList
items={allItems}
itemHeight={50}
renderItem={(item) => <div style={{ borderBottom: '1px solid #eee', padding: '10px' }}>{item.name}</div>}
/>
);
};
Finally, one of the most elegant patterns for complex UIs is the Compound Component pattern. This is where a set of components work together, sharing implicit state, but give the developer full control over the markup. Think of a <select> and <option> in HTML. The <select> manages the state, but you, the developer, define the options. We can build our own components with this relationship.
Let's build a custom Tabs component.
import React, { createContext, useState, useContext } from 'react';
// Create a context for sharing state between Tab components
const TabsContext = createContext();
const Tabs = ({ children, defaultActiveId }) => {
const [activeId, setActiveId] = useState(defaultActiveId);
return (
<TabsContext.Provider value={{ activeId, setActiveId }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
};
const TabList = ({ children }) => {
return <div className="tab-list">{children}</div>;
};
const Tab = ({ id, children }) => {
const { activeId, setActiveId } = useContext(TabsContext);
const isActive = activeId === id;
return (
<button
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => setActiveId(id)}
role="tab"
aria-selected={isActive}
>
{children}
</button>
);
};
const TabPanels = ({ children }) => {
return <div className="tab-panels">{children}</div>;
};
const TabPanel = ({ id, children }) => {
const { activeId } = useContext(TabsContext);
const isActive = activeId === id;
// Only render the active panel for performance
return isActive ? <div className="tab-panel">{children}</div> : null;
};
// Usage - The developer has full control over structure
const App = () => {
return (
<Tabs defaultActiveId="home">
<TabList>
<Tab id="home">Home</Tab>
<Tab id="profile">Profile</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
<TabPanels>
<TabPanel id="home">
<h2>Welcome Home</h2>
<p>This is the home panel content.</p>
</TabPanel>
<TabPanel id="profile">
<h2>Your Profile</h2>
<p>Manage your account details here.</p>
</TabPanel>
<TabPanel id="settings">
<h2>Settings</h2>
<p>Adjust your application settings.</p>
</TabPanel>
</TabPanels>
</Tabs>
);
};
The beauty here is that the Tabs component manages which tab is active internally. The Tab and TabPanel components tap into that state via context, without me having to pass props manually. Yet, I can arrange the markup however I like.
Bringing these techniques together—separation of concerns, custom hooks, context, performance optimization, and compound components—creates a foundation that can handle immense complexity. It allows me to build applications where components are like well-designed, modular building blocks. They can be tested in isolation, reused in unexpected places, and rearranged as the product evolves. The goal isn't just to make things work today, but to create a codebase that remains clear and adaptable for the developers who will work on it next year, or five years from now. That’s what scalable architecture truly means: building a city that can grow without collapsing into chaos.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)