React has become the default choice for building user interfaces, but it’s not always necessary. For many projects, a simple, reactive UI can be built with just a few hundred lines of vanilla JavaScript—no build tools, no transpilers, no dependencies. This article explores how to create a minimal UI library from scratch, demystifying the core concepts behind React and showing that you can have component-based architecture, state management, and efficient DOM updates without the overhead.
By understanding how frameworks work under the hood, you’ll become a better developer, whether you stick with React or go vanilla. Let’s dive into the world of custom UI libraries and see what it takes to build your own.
Why Consider a Custom UI Library?
React is powerful, but it comes with a cost: a large bundle size, a learning curve, and a complex build setup. For small projects, static sites, or simple widgets, this overhead can be overkill. A minimal UI library written in vanilla JavaScript offers several benefits:
- No dependencies: You control every line of code, reducing security risks and maintenance burdens.
- Fast loading: With no framework to download, your pages load faster, especially on slow connections.
- Simpler debugging: You know exactly what your code does, making it easier to trace bugs.
- Educational value: Building your own library teaches you the core concepts behind React, making you a more effective developer.
Of course, this approach isn’t for everyone. React provides a mature ecosystem, developer tools, and team scalability. But for many use cases, a custom solution is not only viable but also elegant.
Core Concepts: State, Rendering, and DOM Updates
To build a reactive UI library, you need three things:
- State management: A way to store and update data that triggers UI updates.
- Rendering: A function that converts state into DOM elements.
- DOM updates: A mechanism to apply changes to the actual page efficiently.
React uses a virtual DOM to minimize direct manipulation, but you can achieve similar results with simpler techniques.
State Management with a Simple Store
The simplest approach is to create a state object and a way to subscribe to changes. Here’s a minimal implementation using a Proxy:
“`javascript
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
const proxy = new Proxy(state, {
set(target, property, value) {
target[property] = value;
listeners.forEach(listener => listener());
return true;
}
});
return {
get state() { return proxy; },
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}
“`
This store lets you read and modify state, and it notifies subscribers whenever a property changes. It’s a simple reactive system.
Rendering Components as Functions
In React, components are functions that return JSX. In vanilla JS, you can have functions that return DOM nodes. For example:
javascript
function Counter({ count, onIncrement }) {
const div = document.createElement('div');
const button = document.createElement('button');
button.textContent = 'Increment';
button.addEventListener('click', onIncrement);
const span = document.createElement('span');
span.textContent = `Count: ${count}`;
div.append(span, button);
return div;
}
This function takes props and returns a DOM element. It’s pure and easy to test.
Efficient DOM Updates with Targeted Re-renders
Instead of a virtual DOM, you can re-render only the parts that change. One approach is to use a simple diffing algorithm that compares old and new DOM trees. But for small apps, you can simply re-render the entire component tree on state changes, as long as you’re careful with event listeners.
A more efficient method is to use a MutationObserver or to manually update specific nodes. For example, you can store references to DOM nodes and update their text content directly:
javascript
function updateCounter() {
span.textContent = `Count: ${store.state.count}`;
}
This avoids re-creating the whole DOM, which is faster for small updates.
Building a Minimal Library: Step-by-Step
Let’s put it all together into a tiny library. We’ll create a createApp function that takes a root component and mounts it to the DOM.
“`javascript
function createApp(Component, root) {
const store = createStore({});
let currentVNode = null;
function render() {
const newVNode = Component(store.state);
if (currentVNode) {
// Simple diff: replace the root element
root.replaceChild(newVNode, currentVNode);
} else {
root.appendChild(newVNode);
}
currentVNode = newVNode;
}
store.subscribe(render);
render();
return {
setState: (updater) => {
// Merge state changes
Object.assign(store.state, updater);
}
};
}
“`
This is a bare-bones version, but it demonstrates the core idea: a component function, a store, and a render loop.
Handling Events and User Interaction
In React, you attach event handlers via JSX. In vanilla, you use addEventListener. When you re-render, you need to avoid attaching duplicate listeners. One way is to use event delegation: attach a single listener to a parent and handle events based on event.target. This is efficient and avoids cleanup issues.
For example:
javascript
root.addEventListener('click', (event) => {
if (event.target.matches('button')) {
// Handle button click
}
});
This way, you don’t need to re-attach listeners on every render.
Managing Lists and Keys
React uses key props to efficiently update lists. In a minimal library, you can use a similar approach: give each list item a unique key and use it to identify which items to update or remove.
A simple diffing algorithm for lists might look like:
javascript
function diffLists(oldList, newList, parent) {
// Remove old items not in new list
// Add new items
// Update existing items by key
}
This is more complex, but for small lists, you can simply re-render the entire list container.
Performance Considerations
Direct DOM manipulation can be faster than a virtual DOM for small apps because you avoid the overhead of creating and diffing virtual trees. However, for large apps with frequent updates, React’s optimizations (like batching and memoization) become valuable.
In your custom library, you can optimize by:
- Using
requestAnimationFrameto batch updates. - Avoiding unnecessary re-renders by comparing state changes.
- Using
document.createDocumentFragmentfor batch DOM insertions.
When to Use Vanilla vs. React
A custom library is great for:
- Learning purposes.
- Small widgets or embedded components.
- Projects where you want zero dependencies.
- Situations where you need fine-grained control over performance.
React is better for:
- Large, complex applications.
- Teams that need consistent patterns and tooling.
- Projects that benefit from React’s ecosystem (hooks, suspense, etc.).
Conclusion
Building a minimal UI library in vanilla JavaScript is not only possible but also a valuable exercise. It demystifies React’s internals and gives you a deeper appreciation for the work frameworks do. While it’s not a replacement for React in production, it’s a powerful tool for learning and for simple projects. So next time you reach for React, consider whether you really need it—or if a few hundred lines of vanilla JS will do the job.
In the end, the choice between React and a custom vanilla solution depends on your project’s needs. For small, focused applications, a minimal library can be simpler, faster, and more transparent. For large-scale projects, React’s ecosystem and optimizations are hard to beat. But regardless of your choice, understanding how to build a UI library from scratch will make you a more versatile and knowledgeable developer.
Summary
- You can build a reactive UI library in vanilla JS with just a few hundred lines of code.
- Core concepts include state management, component functions, and efficient DOM updates.
- A simple store with a Proxy can handle reactivity, and components can be plain functions returning DOM nodes.
- Event delegation and targeted updates can avoid performance pitfalls.
- This approach is educational and suitable for small projects, but React remains better for complex apps.
FAQ
Q: Is this library production-ready?
A: No, it’s a minimal educational example. It lacks features like keyed list diffing, lifecycle methods, and error boundaries that you’d need for a real app.
Q: How does this compare to Preact or Hyperapp?
A: Those are more mature and feature-complete. This is a from-scratch implementation to teach concepts, not a drop-in replacement.
Q: Do I need to know advanced JavaScript to understand this?
A: A basic understanding of functions, objects, and DOM manipulation is enough. The article uses modern features like Proxy, but they’re explained.
Q: Can I use this in a real project?
A: You could, but be prepared to handle edge cases yourself. It’s better suited for learning or for very simple widgets.
Q: Why not just use React?
A: For small projects, React’s overhead (bundle size, build setup) may not be worth it. A custom solution gives you full control and a smaller footprint.