You're Not Ready For React - 2/4
From Pain Comes Innovation
You’ve experienced the raw terror of vanilla DOM manipulation. You’ve written hundreds of lines of code just to display a simple user list. You’ve wrestled with event listeners, battled memory leaks, and manually synchronized state between your data and the UI. By now, you’re probably wondering if there’s a better way.
There is. And today, we’re going to build it.
Instead of jumping straight into React and trusting it blindly, we’re going to implement our own mini-framework. You’ll understand exactly how modern frontend frameworks work under the hood because you’ll build the core concepts yourself. By the end of this article, you’ll not only appreciate React’s brilliance—you’ll understand why it makes the choices it does.
We’re going to solve three fundamental problems:
- The Verbosity Problem: Too much boilerplate for simple operations
- The State Synchronization Problem: Keeping data and UI in sync
- The Performance Problem: Minimizing expensive DOM operations
Let’s build something beautiful.
The Component Abstraction: Your First Framework Feature
The first insight that revolutionized frontend development was simple: UIs are made of reusable pieces. Instead of thinking about individual DOM nodes, we should think about components—self-contained units that know how to render themselves.
Let’s start by creating a basic component system:
// Our mini-framework: MiniReact v0.1
interface ComponentProps {
[key: string]: any;
}
interface ComponentState {
[key: string]: any;
}
abstract class Component {
protected props: ComponentProps;
protected state: ComponentState;
private element: HTMLElement | null = null;
private mounted: boolean = false;
constructor(props: ComponentProps = {}) {
this.props = props;
this.state = {};
}
// Abstract method that components must implement
abstract render(): string;
// Lifecycle methods
componentDidMount(): void {
// Override in subclasses
}
componentDidUpdate(prevState: ComponentState): void {
// Override in subclasses
}
componentWillUnmount(): void {
// Override in subclasses
}
// State management
setState(newState: Partial<ComponentState>): void {
const prevState = { ...this.state };
this.state = { ...this.state, ...newState };
if (this.mounted) {
this.update();
this.componentDidUpdate(prevState);
}
}
// DOM mounting and updating
mount(container: HTMLElement): void {
const html = this.render();
container.innerHTML = html;
this.element = container.firstElementChild as HTMLElement;
this.mounted = true;
this.attachEventListeners();
this.componentDidMount();
}
private update(): void {
if (!this.element || !this.element.parentElement) return;
const newHtml = this.render();
const tempDiv = document.createElement("div");
tempDiv.innerHTML = newHtml;
const newElement = tempDiv.firstElementChild as HTMLElement;
// Simple replacement (we'll make this smarter later)
this.element.parentElement.replaceChild(newElement, this.element);
this.element = newElement;
this.attachEventListeners();
}
unmount(): void {
if (this.element && this.element.parentElement) {
this.componentWillUnmount();
this.element.parentElement.removeChild(this.element);
this.mounted = false;
}
}
// Event handling (to be implemented by subclasses)
protected attachEventListeners(): void {
// Override in subclasses to attach events
}
}
Now let’s create our first real component:
interface User {
id: number;
name: string;
username: string;
email: string;
}
class UserCard extends Component {
constructor(props: { user: User; onEdit: (user: User) => void }) {
super(props);
}
render(): string {
const { user } = this.props;
return `
<div class="user-card" data-user-id="${user.id}">
<h2>${user.name} (@${user.username})</h2>
<p>${user.email}</p>
<button class="edit-btn">Edit User</button>
<button class="delete-btn">Delete User</button>
</div>
`;
}
protected attachEventListeners(): void {
if (!this.element) return;
const editBtn = this.element.querySelector(".edit-btn");
const deleteBtn = this.element.querySelector(".delete-btn");
editBtn?.addEventListener("click", () => {
this.props.onEdit(this.props.user);
});
deleteBtn?.addEventListener("click", () => {
this.handleDelete();
});
}
private handleDelete(): void {
if (confirm(`Delete ${this.props.user.name}?`)) {
// Animate out
this.element!.style.transition = "all 0.3s ease";
this.element!.style.transform = "translateX(-100%)";
this.element!.style.opacity = "0";
setTimeout(() => {
this.unmount();
}, 300);
}
}
}
Look how much cleaner this is! Instead of manually creating DOM elements, we define what our component should look like, and the framework handles the details.
The Virtual DOM: Your Performance Savior
The next breakthrough was the realization that DOM operations are expensive, but JavaScript objects are cheap. Instead of directly manipulating the DOM, we can create a lightweight representation of it—a “Virtual DOM”—and only apply the minimal necessary changes to the real DOM.
Let’s implement a simple virtual DOM:
// Virtual DOM implementation
interface VNode {
tag: string;
props: { [key: string]: any };
children: (VNode | string)[];
key?: string;
}
class VirtualDOM {
// Create a virtual node
static createElement(
tag: string,
props: { [key: string]: any } = {},
...children: (VNode | string)[]
): VNode {
return { tag, props, children, key: props.key };
}
// Convert virtual DOM to real DOM
static render(vnode: VNode | string): HTMLElement | Text {
if (typeof vnode === "string") {
return document.createTextNode(vnode);
}
const element = document.createElement(vnode.tag);
// Set attributes
Object.entries(vnode.props).forEach(([key, value]) => {
if (key === "key") return; // Skip key prop
if (key.startsWith("on") && typeof value === "function") {
// Event listener
const eventType = key.slice(2).toLowerCase();
element.addEventListener(eventType, value);
} else {
element.setAttribute(key, value);
}
});
// Append children
vnode.children.forEach((child) => {
element.appendChild(VirtualDOM.render(child));
});
return element;
}
// The magic: diff two virtual DOM trees and apply minimal changes
static diff(
oldVNode: VNode | string | null,
newVNode: VNode | string | null,
parent: HTMLElement,
index: number = 0
): void {
// Node was removed
if (!newVNode && oldVNode) {
parent.removeChild(parent.childNodes[index]);
return;
}
// Node was added
if (newVNode && !oldVNode) {
parent.appendChild(VirtualDOM.render(newVNode));
return;
}
// Node was replaced
if (VirtualDOM.hasChanged(oldVNode, newVNode)) {
parent.replaceChild(
VirtualDOM.render(newVNode!),
parent.childNodes[index]
);
return;
}
// Both are elements - check children
if (typeof newVNode === "object" && typeof oldVNode === "object") {
const element = parent.childNodes[index] as HTMLElement;
// Update props
VirtualDOM.updateProps(element, oldVNode.props, newVNode.props);
// Recursively diff children
const maxLength = Math.max(
oldVNode.children.length,
newVNode.children.length
);
for (let i = 0; i < maxLength; i++) {
VirtualDOM.diff(oldVNode.children[i], newVNode.children[i], element, i);
}
}
}
private static hasChanged(
node1: VNode | string | null,
node2: VNode | string | null
): boolean {
return (
typeof node1 !== typeof node2 ||
(typeof node1 === "string" && node1 !== node2) ||
(typeof node1 === "object" &&
typeof node2 === "object" &&
node1.tag !== node2.tag)
);
}
private static updateProps(
element: HTMLElement,
oldProps: { [key: string]: any },
newProps: { [key: string]: any }
): void {
// Remove old props
Object.keys(oldProps).forEach((key) => {
if (!(key in newProps)) {
if (key.startsWith("on")) {
// Remove event listener (simplified)
const eventType = key.slice(2).toLowerCase();
element.removeEventListener(eventType, oldProps[key]);
} else {
element.removeAttribute(key);
}
}
});
// Set new props
Object.entries(newProps).forEach(([key, value]) => {
if (oldProps[key] !== value) {
if (key.startsWith("on") && typeof value === "function") {
const eventType = key.slice(2).toLowerCase();
element.addEventListener(eventType, value);
} else {
element.setAttribute(key, value);
}
}
});
}
}
// Helper function for JSX-like syntax
const h = VirtualDOM.createElement;
Now we can rewrite our UserCard component to use the Virtual DOM:
class UserCardVirtual extends Component {
private previousVDOM: VNode | null = null;
render(): VNode {
const { user } = this.props;
return h(
"div",
{
class: "user-card",
"data-user-id": user.id.toString(),
key: user.id.toString(),
},
h("h2", {}, `${user.name} (@${user.username})`),
h("p", {}, user.email),
h(
"button",
{
class: "edit-btn",
onclick: () => this.props.onEdit(user),
},
"Edit User"
),
h(
"button",
{
class: "delete-btn",
onclick: () => this.handleDelete(),
},
"Delete User"
)
);
}
mount(container: HTMLElement): void {
const vdom = this.render();
const element = VirtualDOM.render(vdom);
container.appendChild(element);
this.previousVDOM = vdom;
this.mounted = true;
this.componentDidMount();
}
private update(): void {
if (!this.mounted || !this.element?.parentElement) return;
const newVDOM = this.render();
const parent = this.element.parentElement;
const index = Array.from(parent.children).indexOf(this.element);
VirtualDOM.diff(this.previousVDOM, newVDOM, parent, index);
this.previousVDOM = newVDOM;
}
private handleDelete(): void {
if (confirm(`Delete ${this.props.user.name}?`)) {
this.unmount();
}
}
}
State Management: The Data Flow Revolution
Now let’s solve the state synchronization problem with a simple state management system:
type StateListener<T> = (state: T) => void;
class Store<T> {
private state: T;
private listeners: StateListener<T>[] = [];
constructor(initialState: T) {
this.state = initialState;
}
getState(): T {
return this.state;
}
setState(newState: Partial<T>): void {
this.state = { ...this.state, ...newState };
this.notifyListeners();
}
subscribe(listener: StateListener<T>): () => void {
this.listeners.push(listener);
// Return unsubscribe function
return () => {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
};
}
private notifyListeners(): void {
this.listeners.forEach((listener) => listener(this.state));
}
}
// Application state
interface AppState {
users: User[];
loading: boolean;
error: string | null;
}
const store = new Store<AppState>({
users: [],
loading: false,
error: null,
});
Let’s create a connected component that automatically updates when state changes:
abstract class ConnectedComponent<T> extends Component {
protected store: Store<T>;
private unsubscribe: (() => void) | null = null;
constructor(store: Store<T>, props: ComponentProps = {}) {
super(props);
this.store = store;
}
mount(container: HTMLElement): void {
super.mount(container);
// Subscribe to store changes
this.unsubscribe = this.store.subscribe((state) => {
this.onStateChange(state);
});
}
unmount(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
super.unmount();
}
protected abstract onStateChange(state: T): void;
}
class UserList extends ConnectedComponent<AppState> {
constructor(store: Store<AppState>) {
super(store);
}
render(): string {
const state = this.store.getState();
if (state.loading) {
return `<div class="loading">Loading users...</div>`;
}
if (state.error) {
return `<div class="error">${state.error}</div>`;
}
return `
<div class="user-list">
<h2>Users (${state.users.length})</h2>
<div class="user-grid">
${state.users
.map(
(user) => `
<div class="user-card" data-user-id="${user.id}">
<h3>${user.name}</h3>
<p>@${user.username}</p>
<p>${user.email}</p>
</div>
`
)
.join("")}
</div>
</div>
`;
}
protected onStateChange(state: AppState): void {
this.update();
}
protected attachEventListeners(): void {
if (!this.element) return;
// Event delegation for user interactions
this.element.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const userCard = target.closest(".user-card") as HTMLElement;
if (userCard) {
const userId = userCard.dataset.userId;
const user = this.store
.getState()
.users.find((u) => u.id.toString() === userId);
if (user) {
this.handleUserClick(user);
}
}
});
}
private handleUserClick(user: User): void {
console.log("User clicked:", user);
// Handle user interaction
}
}
Putting It All Together: A Complete Mini-Application
Let’s build a complete application using our mini-framework:
class UserApp extends ConnectedComponent<AppState> {
constructor(store: Store<AppState>) {
super(store);
}
render(): string {
const state = this.store.getState();
return `
<div class="app">
<header class="app-header">
<h1>User Management</h1>
<button id="load-users" ${state.loading ? "disabled" : ""}>
${state.loading ? "Loading..." : "Load Users"}
</button>
<button id="clear-users">Clear All</button>
</header>
<main class="app-main">
${this.renderContent()}
</main>
<footer class="app-footer">
<p>Built with MiniReact v0.1</p>
</footer>
</div>
`;
}
private renderContent(): string {
const state = this.store.getState();
if (state.loading) {
return `
<div class="loading-state">
<div class="spinner"></div>
<p>Loading users from API...</p>
</div>
`;
}
if (state.error) {
return `
<div class="error-state">
<h2>Oops! Something went wrong</h2>
<p>${state.error}</p>
<button id="retry-load">Try Again</button>
</div>
`;
}
if (state.users.length === 0) {
return `
<div class="empty-state">
<h2>No users found</h2>
<p>Click "Load Users" to fetch some data</p>
</div>
`;
}
return `
<div class="users-grid">
${state.users
.map(
(user) => `
<div class="user-card" data-user-id="${user.id}">
<div class="user-avatar">
${user.name.charAt(0).toUpperCase()}
</div>
<h3>${user.name}</h3>
<p class="username">@${user.username}</p>
<p class="email">${user.email}</p>
<div class="user-actions">
<button class="edit-btn" data-action="edit">Edit</button>
<button class="delete-btn" data-action="delete">Delete</button>
</div>
</div>
`
)
.join("")}
</div>
`;
}
protected onStateChange(state: AppState): void {
this.update();
}
protected attachEventListeners(): void {
if (!this.element) return;
// Load users button
const loadBtn = this.element.querySelector("#load-users");
loadBtn?.addEventListener("click", this.loadUsers.bind(this));
// Clear users button
const clearBtn = this.element.querySelector("#clear-users");
clearBtn?.addEventListener("click", this.clearUsers.bind(this));
// Retry button (in error state)
const retryBtn = this.element.querySelector("#retry-load");
retryBtn?.addEventListener("click", this.loadUsers.bind(this));
// User interactions (event delegation)
const usersGrid = this.element.querySelector(".users-grid");
usersGrid?.addEventListener("click", this.handleUserAction.bind(this));
}
private async loadUsers(): Promise<void> {
this.store.setState({ loading: true, error: null });
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const users: User[] = await response.json();
this.store.setState({
users,
loading: false,
error: null,
});
} catch (error) {
this.store.setState({
loading: false,
error: error instanceof Error ? error.message : "Failed to load users",
});
}
}
private clearUsers(): void {
if (confirm("Clear all users?")) {
this.store.setState({ users: [], error: null });
}
}
private handleUserAction(e: Event): void {
const target = e.target as HTMLElement;
const action = target.dataset.action;
const userCard = target.closest(".user-card") as HTMLElement;
if (!action || !userCard) return;
const userId = userCard.dataset.userId;
const user = this.store
.getState()
.users.find((u) => u.id.toString() === userId);
if (!user) return;
switch (action) {
case "edit":
this.editUser(user);
break;
case "delete":
this.deleteUser(user);
break;
}
}
private editUser(user: User): void {
const newName = prompt("Enter new name:", user.name);
if (newName && newName !== user.name) {
const users = this.store
.getState()
.users.map((u) => (u.id === user.id ? { ...u, name: newName } : u));
this.store.setState({ users });
}
}
private deleteUser(user: User): void {
if (confirm(`Delete ${user.name}?`)) {
const users = this.store.getState().users.filter((u) => u.id !== user.id);
this.store.setState({ users });
}
}
}
// Initialize the application
const app = new UserApp(store);
const rootElement = document.getElementById("app")!;
app.mount(rootElement);
What We’ve Built (And What It Means)
Look at what we’ve accomplished with just a few hundred lines of code:
Component Abstraction: We can define reusable UI pieces that encapsulate their rendering logic and behavior.
Virtual DOM: We minimize expensive DOM operations by diffing virtual representations and applying only necessary changes.
State Management: We have a predictable data flow where UI updates automatically when state changes.
Lifecycle Management: Components can hook into mount, update, and unmount events for cleanup and initialization.
Event Handling: We use event delegation for performance and automatic cleanup.
This is fundamentally how React works. The concepts you’ve just implemented—components, virtual DOM, state management, and lifecycle methods—are the core building blocks of modern frontend frameworks.
The Limitations We’ve Hit
Our mini-framework works, but it has limitations that real frameworks solve:
Performance: Our diffing algorithm is naive. React’s reconciler is incredibly optimized.
Developer Experience: Writing h('div', {}, ...)
is verbose. JSX makes this much more readable.
Advanced Patterns: We don’t handle complex scenarios like context, refs, or async rendering.
Bundle Size: Real frameworks are optimized for production use and tree-shaking.
Ecosystem: React has thousands of libraries, tools, and community solutions.
What You’ve Really Learned
You didn’t just build a toy framework—you implemented the core concepts that power billions of web applications. When you now encounter React’s:
- Components: You understand they’re abstractions over DOM manipulation
- Virtual DOM: You know it’s an optimization strategy, not magic
- State: You understand the challenge of keeping data and UI synchronized
- Props: You see they’re just a way to pass data between components
- Lifecycle methods: You know they’re hooks into the component’s existence
You’re no longer just a framework user—you’re someone who understands the underlying architecture. That’s the difference between copying tutorials and building real applications.
In our next article, we’ll introduce actual React and show you how it elegantly solves all the rough edges in our mini-framework. But now, when you see JSX, hooks, and React’s component model, you’ll understand exactly why they exist and how they work.
You’ve earned the right to use React. More importantly, you’ve earned the right to have opinions about it.