Component-Based Architecture
Component-Based Architecture organizes applications as collections of self-contained, reusable components. Each component encapsulates its structure (HTML), presentation (CSS), and behavior (JavaScript), providing a clear contract for interaction with other components.
Core Concepts
1. Encapsulation
Components hide their internal implementation details and expose only a public interface. This prevents external code from depending on internal structure.
// Web Component with encapsulation
class UserCard extends HTMLElement {
// Private data (not accessible from outside)
#userData = null;
constructor() {
super();
// Shadow DOM provides true encapsulation
this.attachShadow({ mode: 'open' });
}
// Public interface
set user(data) {
this.#userData = data;
this.#render();
}
// Private method
#render() {
this.shadowRoot.innerHTML = `
<style>
/* These styles won't leak out */
.card {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
}
.name {
font-weight: bold;
font-size: 1.2rem;
}
</style>
<div class="card">
<div class="name">${this.#userData?.name || 'Unknown'}</div>
<div class="email">${this.#userData?.email || 'No email'}</div>
</div>
`;
}
}
customElements.define('user-card', UserCard); Usage in HTML:
<!-- Clean, declarative component use -->
<user-card></user-card>
<script is:inline>
const card = document.querySelector('user-card');
// Only public interface is accessible
card.user = {
name: 'Alice Johnson',
email: 'alice@example.com'
};
</script>
<!-- Styles from the component don't affect the page -->
<style>
.card { background: red; } /* Won't affect the component's .card */
</style> 2. Reusability
Components can be used multiple times throughout an application. They maintain their own state and behavior regardless of where they're placed.
// Reusable button component
class ActionButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const variant = this.getAttribute('variant') || 'primary';
const disabled = this.hasAttribute('disabled');
this.shadowRoot.innerHTML = `
<style>
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button.primary { background: #007bff; color: white; }
button.secondary { background: #6c757d; color: white; }
button.danger { background: #dc3545; color: white; }
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<button class="${variant}" ${disabled ? 'disabled' : ''}>
<slot></slot>
</button>
`;
// Forward click events
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new Event('action', { bubbles: true }));
});
}
}
customElements.define('action-button', ActionButton); Reuse across the application:
<!-- Same component, different contexts -->
<action-button variant="primary">Save</action-button>
<action-button variant="secondary">Cancel</action-button>
<action-button variant="danger">Delete</action-button>
<action-button disabled>Processing...</action-button> 3. Composition
Complex UIs are built by composing smaller components together. Components can contain other components, creating a component hierarchy.
// Container component that composes other components
class UserProfile extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
set user(data) {
this._user = data;
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.profile {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>
<div class="profile">
<!-- Composed of smaller components -->
<user-avatar src="${this._user.avatar}"></user-avatar>
<user-card></user-card>
<user-stats></user-stats>
<action-button variant="primary">Edit Profile</action-button>
</div>
`;
// Pass data down to child components
this.shadowRoot.querySelector('user-card').user = this._user;
this.shadowRoot.querySelector('user-stats').stats = {
posts: this._user.postCount,
followers: this._user.followerCount
};
}
}
customElements.define('user-profile', UserProfile); 4. Lifecycle Management
Web Components have lifecycle callbacks that allow components to respond to creation, insertion, updates, and removal.
class DataTable extends HTMLElement {
constructor() {
super();
// Called when element is created
console.log('Component created');
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Called when element is added to DOM
console.log('Component mounted to DOM');
this.fetchData();
}
disconnectedCallback() {
// Called when element is removed from DOM
console.log('Component removed from DOM');
this.cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
// Called when observed attributes change
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
if (name === 'data-source') {
this.fetchData();
}
}
// Tell which attributes to observe
static get observedAttributes() {
return ['data-source'];
}
async fetchData() {
const source = this.getAttribute('data-source');
if (source) {
const response = await fetch(source);
this.data = await response.json();
this.render();
}
}
cleanup() {
// Cancel any pending requests, clear timers, etc.
if (this._timer) {
clearInterval(this._timer);
}
}
render() {
// Update the DOM
}
}
customElements.define('data-table', DataTable); Component Communication
Props (Attributes and Properties)
Parent components pass data down to children via attributes or properties.
class ProgressBar extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
// Property setter
set value(val) {
this.setAttribute('value', val);
}
get value() {
return this.getAttribute('value') || '0';
}
static get observedAttributes() {
return ['value', 'max'];
}
attributeChangedCallback() {
this.render();
}
render() {
const value = parseInt(this.value);
const max = parseInt(this.getAttribute('max') || '100');
const percent = (value / max) * 100;
this.shadowRoot.innerHTML = `
<style>
.progress {
width: 100%;
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
.bar {
height: 100%;
background: #4caf50;
transition: width 0.3s ease;
}
</style>
<div class="progress">
<div class="bar" style="width: ${percent}%"></div>
</div>
`;
}
connectedCallback() {
this.render();
}
}
customElements.define('progress-bar', ProgressBar); Usage:
<!-- Via attributes -->
<progress-bar value="75" max="100"></progress-bar>
<!-- Via properties -->
<script is:inline>
const bar = document.querySelector('progress-bar');
bar.value = 50; // Updates the component
</script> Slots for Content Projection
Slots allow parent components to pass content (including other components) into specific locations within a child component.
class DialogBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.dialog {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
background: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
}
.footer {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
<div class="dialog">
<div class="header">
<slot name="title">Default Title</slot>
</div>
<div class="content">
<slot>Default content</slot>
</div>
<div class="footer">
<slot name="actions"></slot>
</div>
</div>
`;
}
}
customElements.define('dialog-box', DialogBox); Using slots:
<dialog-box>
<!-- Named slot -->
<span slot="title">Confirm Delete</span>
<!-- Default slot -->
<p>Are you sure you want to delete this item? This action cannot be undone.</p>
<!-- Named slot -->
<div slot="actions">
<action-button variant="secondary">Cancel</action-button>
<action-button variant="danger">Delete</action-button>
</div>
</dialog-box> Benefits
- Modularity: Each component is a self-contained module with clear boundaries
- Reusability: Write once, use anywhere in the application
- Maintainability: Changes to a component are isolated and don't affect other parts
- Testability: Components can be tested in isolation
- Parallel Development: Teams can work on different components simultaneously
- Native Browser Support: Web Components are a web standard supported by all modern browsers
Challenges
- Initial Complexity: Requires understanding of Custom Elements, Shadow DOM, and templates
- State Management: Complex apps need patterns for sharing state between components
- Performance: Too many components can add overhead; balance is needed
- Browser Support: Very old browsers may need polyfills
Real-World Example: Todo List
// Todo Item Component
class TodoItem extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
set todo(data) {
this._todo = data;
this.render();
}
render() {
const { id, text, completed } = this._todo;
this.shadowRoot.innerHTML = `
<style>
.todo-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #eee;
}
.completed { text-decoration: line-through; opacity: 0.6; }
</style>
<div class="todo-item">
<input type="checkbox" ${completed ? 'checked' : ''}>
<span class="${completed ? 'completed' : ''}">${text}</span>
<button class="delete">Delete</button>
</div>
`;
// Event handlers
this.shadowRoot.querySelector('input').addEventListener('change', (e) => {
this.dispatchEvent(new CustomEvent('toggle', {
detail: { id },
bubbles: true
}));
});
this.shadowRoot.querySelector('.delete').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('delete', {
detail: { id },
bubbles: true
}));
});
}
}
// Todo List Component
class TodoList extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.todos = [];
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
setupEventListeners() {
// Listen for events from child components
this.addEventListener('toggle', (e) => {
this.toggleTodo(e.detail.id);
});
this.addEventListener('delete', (e) => {
this.deleteTodo(e.detail.id);
});
}
addTodo(text) {
this.todos.push({
id: Date.now(),
text,
completed: false
});
this.render();
}
toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
this.render();
}
}
deleteTodo(id) {
this.todos = this.todos.filter(t => t.id !== id);
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.container {
max-width: 500px;
margin: 0 auto;
}
.input-area {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
input {
flex: 1;
padding: 0.5rem;
}
</style>
<div class="container">
<div class="input-area">
<input type="text" placeholder="Add todo..." />
<button>Add</button>
</div>
<div class="todo-items"></div>
</div>
`;
// Render todo items
const container = this.shadowRoot.querySelector('.todo-items');
this.todos.forEach(todo => {
const item = document.createElement('todo-item');
item.todo = todo;
container.appendChild(item);
});
// Add input handler
const input = this.shadowRoot.querySelector('input');
const button = this.shadowRoot.querySelector('button');
button.addEventListener('click', () => {
if (input.value.trim()) {
this.addTodo(input.value);
input.value = '';
}
});
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && input.value.trim()) {
this.addTodo(input.value);
input.value = '';
}
});
}
}
customElements.define('todo-item', TodoItem);
customElements.define('todo-list', TodoList); Usage:
<!DOCTYPE html>
<html>
<head>
<title>Todo App</title>
</head>
<body>
<!-- Entire application in one component -->
<todo-list></todo-list>
<script src="todo-components.js"></script>
</body>
</html> Key Takeaways
- Components encapsulate HTML, CSS, and JavaScript into reusable units
- Shadow DOM provides true style and DOM encapsulation
- Custom Elements API allows creation of new HTML tags
- Components communicate through props (down) and events (up)
- Slots enable flexible content composition
- Lifecycle callbacks handle creation, updates, and cleanup
- Web Components are native browser features - no framework required
See Also
- Event-Driven Architecture - Components communicate via events
- Layered Architecture - Separation of structure, presentation, and behavior
- Single Page Application - SPAs are often built with component frameworks