Software Architecture Patterns in Frontend Development
Frontend architecture patterns provide structured approaches to building scalable, maintainable, and performant web applications. The right pattern depends on your application's complexity, team size, and specific requirements.
Component-Based Patterns
1. Atomic Design
Organizes UI components into a hierarchical structure inspired by chemistry.
Structure:
Atoms: Basic building blocks (buttons, inputs, labels)
Molecules: Simple groups of atoms (search form, card header)
Organisms: Complex components (navigation bar, form sections)
Templates: Page-level layouts without data
Pages: Specific instances with real content
Benefits:
Clear component hierarchy
Promotes reusability
Easier design system maintenance
Better collaboration between designers and developers
Use Cases:
Design systems
Large-scale applications
Teams with dedicated design resources
Example:
// Atom
const Button = ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
);
// Molecule
const SearchBar = () => (
<form>
<Input placeholder="Search..." />
<Button>Search</Button>
</form>
);
// Organism
const Header = () => (
<header>
<Logo />
<Navigation />
<SearchBar />
<UserMenu />
</header>
);2. Container/Presentational Pattern
Separates business logic from UI presentation.
Components:
Presentational (Dumb): Focus on how things look
Receive data via props
Rarely have state
Purely visual
Container (Smart): Focus on how things work
Handle data fetching
Manage state
Pass data to presentational components
Benefits:
Clear separation of concerns
Easier testing
Better reusability of presentational components
Simplified logic debugging
Example:
// Presentational Component
const UserList = ({ users, onUserClick }) => (
<ul>
{users.map(user => (
<li key={user.id} onClick={() => onUserClick(user)}>
{user.name}
</li>
))}
</ul>
);
// Container Component
const UserListContainer = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
fetchUsers().then(setUsers);
}, []);
const handleUserClick = (user) => {
// Handle business logic
console.log('User clicked:', user);
};
return <UserList users={users} onUserClick={handleUserClick} />;
};3. Compound Components
Components that work together to form a complete UI, sharing implicit state.
Benefits:
Flexible API
Encapsulated logic
Better developer experience
Reduced prop drilling
Example:
// Parent manages state, children share it
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState(0);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
};
Tabs.List = ({ children }) => <div className="tab-list">{children}</div>;
Tabs.Tab = ({ index, children }) => {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
className={activeTab === index ? 'active' : ''}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
};
Tabs.Panel = ({ index, children }) => {
const { activeTab } = useContext(TabsContext);
return activeTab === index ? <div>{children}</div> : null;
};
// Usage
<Tabs>
<Tabs.List>
<Tabs.Tab index={0}>Tab 1</Tabs.Tab>
<Tabs.Tab index={1}>Tab 2</Tabs.Tab>
</Tabs.List>
<Tabs.Panel index={0}>Content 1</Tabs.Panel>
<Tabs.Panel index={1}>Content 2</Tabs.Panel>
</Tabs>State Management Patterns
1. Flux Architecture
Unidirectional data flow pattern popularized by Facebook.
Flow:
Action → Dispatcher → Store → View → ActionComponents:
Actions: Describe what happened
Dispatcher: Central hub managing all data flow
Stores: Hold application state and logic
Views: React to store changes
Benefits:
Predictable state changes
Easier debugging
Clear data flow
Better separation of concerns
Example:
// Action
const addTodo = (text) => ({
type: 'ADD_TODO',
payload: { text }
});
// Store
class TodoStore extends EventEmitter {
constructor() {
super();
this.todos = [];
}
handleAction(action) {
switch(action.type) {
case 'ADD_TODO':
this.todos.push(action.payload);
this.emit('change');
break;
}
}
getTodos() {
return this.todos;
}
}
// Dispatcher
const dispatcher = new Dispatcher();
dispatcher.register(todoStore.handleAction.bind(todoStore));2. Redux Pattern
Predictable state container based on Flux principles.
Principles:
Single source of truth (one store)
State is read-only
Changes via pure functions (reducers)
Benefits:
Time-travel debugging
Predictable state updates
Excellent DevTools
Middleware support
Example:
// Reducer
const todosReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO':
return [...state, action.payload];
case 'REMOVE_TODO':
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
};
// Store
const store = createStore(todosReducer);
// Component
const TodoApp = () => {
const todos = useSelector(state => state.todos);
const dispatch = useDispatch();
const addTodo = (text) => {
dispatch({ type: 'ADD_TODO', payload: { id: Date.now(), text } });
};
return <TodoList todos={todos} onAdd={addTodo} />;
};3. Context + Hooks Pattern
Modern React approach using Context API and custom hooks.
Benefits:
No external dependencies
Simple for small to medium apps
Great for theme, auth, i18n
Type-safe with TypeScript
Example:
// Context
const AuthContext = createContext();
// Provider
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const login = async (credentials) => {
const user = await authAPI.login(credentials);
setUser(user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
};
// Custom Hook
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
// Usage
const Profile = () => {
const { user, logout } = useAuth();
return <div>Welcome {user.name} <button onClick={logout}>Logout</button></div>;
};4. Observable Pattern (RxJS/MobX)
Reactive state management using observables.
Benefits:
Reactive programming paradigm
Automatic dependency tracking
Minimal boilerplate
Fine-grained reactivity
Example (MobX):
import { makeObservable, observable, action, computed } from 'mobx';
import { observer } from 'mobx-react-lite';
class TodoStore {
todos = [];
constructor() {
makeObservable(this, {
todos: observable,
addTodo: action,
completedCount: computed
});
}
addTodo(text) {
this.todos.push({ id: Date.now(), text, completed: false });
}
get completedCount() {
return this.todos.filter(t => t.completed).length;
}
}
const todoStore = new TodoStore();
const TodoApp = observer(() => {
return (
<div>
<p>Completed: {todoStore.completedCount}</p>
<button onClick={() => todoStore.addTodo('New task')}>Add</button>
</div>
);
});Architectural Patterns
1. MVC (Model-View-Controller)
Traditional separation of data, presentation, and logic.
Components:
Model: Data and business logic
View: UI presentation
Controller: Handles user input, updates model
Benefits:
Clear separation of concerns
Easier testing
Parallel development
Modern Frontend Interpretation:
// Model
class UserModel {
constructor(data) {
this.data = data;
}
async save() {
return await api.saveUser(this.data);
}
validate() {
return this.data.email && this.data.name;
}
}
// View
const UserView = ({ user, onSave }) => (
<form onSubmit={onSave}>
<input value={user.name} />
<input value={user.email} />
<button type="submit">Save</button>
</form>
);
// Controller
const UserController = () => {
const [user, setUser] = useState(new UserModel({}));
const handleSave = async (e) => {
e.preventDefault();
if (user.validate()) {
await user.save();
}
};
return <UserView user={user.data} onSave={handleSave} />;
};2. MVVM (Model-View-ViewModel)
Two-way data binding between View and ViewModel.
Components:
Model: Data layer
View: UI layer
ViewModel: Intermediary with binding logic
Benefits:
Declarative UI
Automatic synchronization
Better testability
Example (Vue.js naturally follows MVVM):
// Model
const userModel = {
fetchUser(id) {
return api.get(`/users/${id}`);
}
};
// ViewModel
export default {
data() {
return {
user: {
name: '',
email: ''
}
};
},
computed: {
isValid() {
return this.user.name && this.user.email;
}
},
methods: {
async loadUser(id) {
this.user = await userModel.fetchUser(id);
},
async saveUser() {
if (this.isValid) {
await api.saveUser(this.user);
}
}
}
};
// View (template)
// <div>
// <input v-model="user.name" />
// <input v-model="user.email" />
// <button @click="saveUser" :disabled="!isValid">Save</button>
// </div>3. Feature-Sliced Design
Modern architectural methodology organizing code by features and layers.
Layers (top to bottom):
App: Application initialization
Processes: Complex inter-page scenarios
Pages: Full pages
Widgets: Large independent blocks
Features: User scenarios and actions
Entities: Business entities
Shared: Reusable code
Benefits:
Scalable structure
Clear dependencies
Easy navigation
Team-friendly
Structure:
src/
├── app/
│ ├── providers/
│ ├── styles/
│ └── index.tsx
├── pages/
│ ├── home/
│ └── profile/
├── widgets/
│ ├── header/
│ └── sidebar/
├── features/
│ ├── auth/
│ │ ├── ui/
│ │ ├── model/
│ │ └── api/
│ └── todo-create/
├── entities/
│ ├── user/
│ └── todo/
└── shared/
├── ui/
├── lib/
└── api/4. Clean Architecture
Dependency rule: inner layers don't know about outer layers.
Layers:
Domain: Business logic (entities, use cases)
Application: Application logic (services, state)
Infrastructure: External concerns (API, storage)
Presentation: UI components
Benefits:
Framework independence
Testability
UI independence
Database independence
Example:
// Domain Layer (innermost)
class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
}
isValid() {
return this.name && this.email.includes('@');
}
}
// Application Layer (use cases)
class CreateUserUseCase {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(userData) {
const user = new User(null, userData.name, userData.email);
if (!user.isValid()) {
throw new Error('Invalid user data');
}
return await this.userRepository.create(user);
}
}
// Infrastructure Layer
class UserRepository {
async create(user) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user)
});
return response.json();
}
}
// Presentation Layer
const CreateUserForm = () => {
const createUser = new CreateUserUseCase(new UserRepository());
const handleSubmit = async (formData) => {
try {
await createUser.execute(formData);
} catch (error) {
// Handle error
}
};
return <form onSubmit={handleSubmit}>...</form>;
};Data Flow Patterns
1. Unidirectional Data Flow
Data flows in one direction through the application.
Flow:
State → View → Action → State Update → View UpdateBenefits:
Predictable updates
Easier debugging
Time-travel possible
Clear data provenance
2. Bidirectional Data Binding
View and model stay in sync automatically.
Use Cases:
Forms with many fields
Real-time collaboration
Configuration UIs
Trade-offs:
Can be harder to debug
Less predictable with complex interactions
Performance considerations
3. Event-Driven Architecture
Components communicate through events.
Benefits:
Loose coupling
Scalability
Easy to add features
Asynchronous handling
Example:
// Event Bus
class EventBus {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
emit(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => callback(data));
}
}
}
const eventBus = new EventBus();
// Component A
eventBus.emit('user:login', { userId: 123 });
// Component B
eventBus.on('user:login', (data) => {
console.log('User logged in:', data.userId);
});Micro Frontend Patterns
1. Build-Time Integration
Micro frontends composed during build process.
Approaches:
NPM packages
Git submodules
Monorepo with shared build
Benefits:
Simple deployment
Better performance
Type safety across apps
Trade-offs:
Rebuild required for updates
Tight coupling at build time
2. Run-Time Integration
Apps integrated in the browser.
Approaches:
Module Federation (Webpack 5)
iframes
Web Components
Example (Module Federation):
// Remote App (webpack.config.js)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button'
}
})
]
};
// Host App
const RemoteButton = React.lazy(() => import('remoteApp/Button'));
function App() {
return (
<Suspense fallback="Loading...">
<RemoteButton />
</Suspense>
);
}3. Server-Side Integration
Composition happens on the server.
Approaches:
Server-Side Includes (SSI)
Edge-Side Includes (ESI)
Reverse proxy composition
Benefits:
Initial load performance
SEO friendly
Progressive enhancement
Performance Patterns
1. Code Splitting
Load code only when needed.
Strategies:
Route-based splitting
Component-based splitting
Vendor splitting
Example:
// Route-based
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
// Component-based
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
);
}2. Virtual Scrolling
Render only visible items in long lists.
Benefits:
Handles thousands of items
Constant memory usage
Smooth scrolling
Example:
import { FixedSizeList } from 'react-window';
const VirtualList = ({ items }) => {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
};3. Memoization
Cache expensive computations.
Techniques:
React.memo for components
useMemo for values
useCallback for functions
Example:
// Expensive component only re-renders when props change
const ExpensiveComponent = React.memo(({ data }) => {
return <div>{/* Complex rendering */}</div>;
});
// Expensive calculation
const ProcessedData = ({ rawData }) => {
const processed = useMemo(() => {
return rawData.map(item => expensiveTransform(item));
}, [rawData]);
return <List data={processed} />;
};4. Lazy Loading
Defer loading of non-critical resources.
Strategies:
Below-the-fold images
Route-based components
Modal/drawer contents
Third-party widgets
Example:
// Image lazy loading
<img
src="placeholder.jpg"
data-src="actual-image.jpg"
loading="lazy"
alt="Description"
/>
// Intersection Observer approach
const LazyImage = ({ src, alt }) => {
const [imageSrc, setImageSrc] = useState('placeholder.jpg');
const imgRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setImageSrc(src);
observer.disconnect();
}
});
observer.observe(imgRef.current);
return () => observer.disconnect();
}, [src]);
return <img ref={imgRef} src={imageSrc} alt={alt} />;
};Best Practices
1. Choose the Right Pattern
Small apps: Context + Hooks, simple component patterns
Medium apps: Redux or MobX, feature-based organization
Large apps: Micro frontends, Clean Architecture, Feature-Sliced Design
Forms-heavy: MVVM-style, bidirectional binding
Data-heavy: Observable patterns, virtual scrolling
2. Maintain Consistency
Document chosen patterns
Create templates and generators
Enforce with linting rules
Regular code reviews
3. Balance Flexibility and Structure
Don't over-engineer for current needs
Build for change, not perfection
Refactor as complexity grows
Keep escape hatches for exceptions
4. Performance Considerations
Measure before optimizing
Use production profiling tools
Consider bundle size impact
Test on real devices and networks
5. Developer Experience
Clear folder structure
Consistent naming conventions
Good TypeScript types
Helpful error messages
Comprehensive documentation
Conclusion
The best architecture pattern depends on your specific context:
Team size and structure: Larger teams benefit from stricter patterns
Application complexity: Complex apps need more sophisticated architectures
Performance requirements: High-performance apps need careful optimization patterns
Development speed: Rapid prototyping favors simpler patterns
Maintainability needs: Long-term projects benefit from clean architecture
Start simple, measure regularly, and evolve your architecture as your application grows. No single pattern fits all scenarios—often the best solution combines multiple patterns strategically.
Comments
No comments yet.