JavaScript Functions: The UI Framework You Already Have
7 mins read

JavaScript Functions: The UI Framework You Already Have

Actually, I hit a wall last Tuesday. It was 11:30 PM, and I was staring at a cryptic error message in my terminal. Something about a “loader configuration” failing to parse a file that I hadn’t touched in six months. The build pipeline—a fragile tower of Babel, Webpack, and three different TypeScript config files—had decided to collapse because I dared to upgrade a single dependency.

But I closed the laptop. Walked away. Made coffee (decaf, obviously, I’m not insane). And I thought: Why is this so hard?

We’ve spent the last decade inventing complex ways to pretend we aren’t writing JavaScript. We invented JSX to pretend we’re writing HTML. We invented hooks to pretend functions have memory. And we built compilers to turn all that pretense back into the thing we were trying to avoid: plain JavaScript functions.

So, I tried something radical. I deleted the node_modules folder. I deleted the package.json. I opened a blank index.html and a script.js. No build step. No transpiler. Just functions.

The “Everything is a Function” Epiphany

Here’s the thing we forget: The DOM is just an object graph. And you don’t need a Virtual DOM to manage it if you structure your code correctly. You just need functions. Composition is baked into the language, after all.

If you strip away the marketing fluff of the big frameworks, a UI component is really just a function that takes data and returns a DOM element. That’s it.

I wrote a tiny helper function—literally 10 lines of code—that I’ve been copy-pasting into every prototype I’ve built since January. And it replaces the entire compilation step of React.

JavaScript code on monitor - VS Code tips — Monitor CPU and memory in realtime while debugging ...
JavaScript code on monitor – VS Code tips — Monitor CPU and memory in realtime while debugging …
// The only "framework" you need
const el = (tag, props = {}, ...children) => {
    const element = document.createElement(tag);
    
    // Handle props
    Object.entries(props).forEach(([key, value]) => {
        if (key.startsWith('on') && typeof value === 'function') {
            element.addEventListener(key.substring(2).toLowerCase(), value);
        } else if (key === 'style') {
            Object.assign(element.style, value);
        } else {
            element.setAttribute(key, value);
        }
    });

    // Handle children
    children.forEach(child => {
        if (typeof child === 'string') {
            element.appendChild(document.createTextNode(child));
        } else if (child instanceof Node) {
            element.appendChild(child);
        }
    });

    return element;
};

Look at that. No magic. No proxy objects trapping your getters. Just a function creating an element.

And when you use this, the hierarchy of your UI matches the hierarchy of your function calls. It’s visually identical to the tree structure we try to emulate with JSX, but it’s valid JavaScript that runs directly in Chrome 142 without a build step.

const Card = (title, content) => 
    el('div', { class: 'card', style: { padding: '20px', border: '1px solid #ccc' } },
        el('h2', {}, title),
        el('p', { style: { color: '#666' } }, content),
        el('button', { 
            class: 'btn-primary',
            onClick: (e) => console.log(Clicked ${title}) 
        }, 'Read More')
    );

// Usage
document.body.appendChild(
    el('div', { id: 'app' },
        Card('The State of JS', 'Functions are eating the world.'),
        Card('No Build Tools', 'Why wait for Webpack?')
    )
);

And I ran this on my M2 MacBook Air, and the paint time was negligible. We’re talking sub-millisecond execution for the entire tree. Why? Because the browser isn’t diffing a virtual tree against a real tree. It’s just executing instructions.

Closures: The Original State Management

People ask me, “But how do you handle state? You need useState, right?”

Well, you need closures. JavaScript has had state management built-in since the 90s. A function remembers the scope in which it was created. And if you want a component to update, you don’t need a re-render cycle triggered by a scheduler. You just need a reference to the DOM node you want to change.

I built a counter component yesterday to test this. And there are no hooks rules to memorize. No dependency arrays to get wrong.

const Counter = () => {
    let count = 0;
    
    // Create the display element first
    const display = el('span', { style: { margin: '0 10px', fontWeight: 'bold' } }, '0');
    
    const update = () => {
        count++;
        display.textContent = count; // Direct DOM manipulation is fast. Fight me.
    };

    return el('div', { class: 'counter-wrapper' },
        el('button', { onClick: () => { count--; display.textContent = count; } }, '-'),
        display,
        el('button', { onClick: update }, '+')
    );
};

This is what I call “Granular Updates.” When the button is clicked, we aren’t re-running the Counter function. We aren’t checking if the virtual DOM changed. We are specifically targeting the textContent of one span. And it’s the most performant way to update a UI, period.

Async Functions and the “Suspense” Myth

TypeScript programming - TypeScript Programming Example - GeeksforGeeks
TypeScript programming – TypeScript Programming Example – GeeksforGeeks

We’ve been sold this idea that handling async data in UI requires complex boundaries and fallback components managed by the framework. But JavaScript has async/await. So why can’t a component just be an async function?

Spoiler: It can.

I was working on a dashboard widget that pulls crypto prices (don’t ask, client work). And the standard React approach would be useEffect to fetch, a state variable for loading, another for data, and a conditional return.

But here’s how I wrote it using pure functions. Note that the function returns a Promise that resolves to a DOM node. This means you can just await your UI.

const UserProfile = async (userId) => {
    try {
        const res = await fetch(https://jsonplaceholder.typicode.com/users/${userId});
        const user = await res.json();
        
        return el('div', { class: 'profile' },
            el('h3', {}, user.name),
            el('p', {}, Email: ${user.email}),
            el('p', {}, City: ${user.address.city})
        );
    } catch (err) {
        return el('div', { class: 'error' }, 'Failed to load user');
    }
};

// Orchestrating it
(async () => {
    const app = document.getElementById('app');
    const loader = el('div', {}, 'Loading profile...');
    app.appendChild(loader);

    const profile = await UserProfile(1);
    
    app.replaceChild(profile, loader);
})();

And I tested this pattern with a slow 3G network throttle in Chrome DevTools. It behaves exactly how you’d expect. You have full control over the loading sequence without needing a framework to orchestrate it for you. It’s just promises.

TypeScript programming - Programming language TypeScript: advantages, and disadvantages
TypeScript programming – Programming language TypeScript: advantages, and disadvantages

The Reality Check

Now, I know what you’re thinking. “This doesn’t scale.” “What about prop drilling?” “What about context?”

But I used to think that too. And recently I looked at a project I built in 2019. It had 45 dependencies. Today, 12 of them are deprecated, and the build takes 4 minutes. The project I built last month using this function-based approach? Zero dependencies. It opens instantly. And it will still work in 2030 because document.createElement isn’t going anywhere.

There is a trade-off, of course. You lose the ecosystem. You can’t just npm install a date picker (well, you can, but you have to wire it up yourself). But in exchange, you get sanity. You get a codebase that you can actually understand from top to bottom.

And if you’re building the next Facebook, sure, use the heavy tools. But for that dashboard, that internal tool, or that personal site? Just use functions. They’re powerful enough.

Leave a Reply

Your email address will not be published. Required fields are marked *