Back to blog

Minimalist JavaScript: How to Escape Framework Fatigue and Build Better Apps in 2025

Hey HaWkers, are you tired of learning a new JavaScript framework every 6 months? Feeling overwhelmed by the complexity of modern frontend development?

You're not alone. 67% of developers report "framework fatigue" in 2025 (Stack Overflow Survey). But there's a growing movement: minimalist JavaScript — building powerful apps with less tooling, less complexity, and ironically, more productivity.

The Framework Fatigue Problem

The Modern JavaScript Paradox

// The reality of JavaScript development in 2025

const javaScriptLandscape = {
  numberOfFrameworks: "2,500+ on npm",
  newFrameworksPerMonth: "~50",
  developerSentiment: {
    overwhelmed: "67%",
    enjoyLearningNew: "23%",
    wishForStability: "78%",
    consideringVanillaJS: "42%",
  },

  typicalProjectSetup: {
    framework: "React/Vue/Svelte/Solid/Qwik/...",
    metaFramework: "Next.js/Nuxt/SvelteKit/Astro/...",
    stateManagement: "Redux/Zustand/Pinia/Jotai/...",
    styling: "Tailwind/CSS Modules/Styled-components/...",
    buildTool: "Vite/Webpack/Turbopack/Rspack/...",
    testing: "Jest/Vitest/Playwright/Cypress/...",
    linting: "ESLint + Prettier + TypeScript",

    totalConfigFiles: "15-25 files",
    totalDependencies: "300-800 packages",
    nodeModulesSize: "400MB-1.2GB",
    timeToSetup: "2-4 hours (if you know what you're doing)",
  },

  theQuestion: "Do we really need all of this?",
};

What We Lost Along the Way

// Comparison: 2015 vs 2025

const developmentComplexity = {
  simpleWebsite2015: {
    requirements: "Show user profile with data from API",
    setup: [
      "index.html",
      "script.js",
      "style.css",
    ],
    dependencies: "None (or jQuery)",
    bundle: "~50KB",
    buildTime: "N/A (no build)",
    timeToFirstLine: "30 seconds",

    code: `
      // script.js (2015)
      fetch('/api/user')
        .then(res => res.json())
        .then(user => {
          document.getElementById('name').textContent = user.name;
          document.getElementById('email').textContent = user.email;
        });
    `,
  },

  sameWebsite2025: {
    requirements: "Show user profile with data from API",
    setup: [
      "package.json",
      "tsconfig.json",
      "vite.config.ts",
      "eslint.config.js",
      ".prettierrc",
      "src/App.tsx",
      "src/components/UserProfile.tsx",
      "src/hooks/useUser.ts",
      "src/types/user.ts",
      "src/api/client.ts",
    ],
    dependencies: "150+ packages",
    bundle: "250KB (after optimization)",
    buildTime: "10-20 seconds",
    timeToFirstLine: "2-3 hours (setup + configuration)",

    code: `
      // Modern setup (simplified)
      // types/user.ts
      export interface User {
        name: string;
        email: string;
      }

      // hooks/useUser.ts
      export function useUser() {
        const [user, setUser] = useState<User | null>(null);
        useEffect(() => {
          fetch('/api/user')
            .then(res => res.json())
            .then(setUser);
        }, []);
        return user;
      }

      // components/UserProfile.tsx
      export function UserProfile() {
        const user = useUser();
        if (!user) return <div>Loading...</div>;
        return (
          <div>
            <h1>{user.name}</h1>
            <p>{user.email}</p>
          </div>
        );
      }
    `,
  },

  realityCheck: "10x more complexity for same result",
};

The Case for Minimalist JavaScript

What is Minimalist JavaScript?

// Core principles of minimalist JavaScript development

const minimalistPrinciples = {
  principle1: {
    name: "Use vanilla JavaScript first",
    description: "Only add frameworks when complexity justifies it",
    example: "Simple site? HTML + vanilla JS. Complex app? Consider framework.",
  },

  principle2: {
    name: "Embrace platform APIs",
    description: "Modern browsers are incredibly powerful",
    example: "Fetch API, Web Components, CSS Grid instead of libraries",
  },

  principle3: {
    name: "Question every dependency",
    description: "Each package is tech debt and security risk",
    example: "Do you need moment.js or is Date.toLocaleDateString() enough?",
  },

  principle4: {
    name: "Optimize for maintenance, not initial velocity",
    description: "Fast to write != easy to maintain",
    example: "Simple code you understand > complex abstraction you don't",
  },

  principle5: {
    name: "Progressive enhancement over JavaScript-required",
    description: "Work without JS, enhance with it",
    example: "Forms work via POST, enhanced with fetch for better UX",
  },
};

When Minimalist Approach Makes Sense

// Decision framework for choosing approach

const approachDecisionTree = {
  useVanillaJS: {
    scenarios: [
      "Marketing websites",
      "Landing pages",
      "Content-heavy sites",
      "Simple dashboards",
      "Browser extensions",
      "Small internal tools",
    ],
    benefits: [
      "Zero build step",
      "Instant load times",
      "No dependency updates",
      "Runs forever without maintenance",
      "Easy to understand and debug",
    ],
    limitations: [
      "Manual DOM manipulation",
      "No built-in state management",
      "Requires discipline for large apps",
    ],
  },

  useLightFramework: {
    scenarios: [
      "Interactive UIs with moderate complexity",
      "Apps needing reactive updates",
      "Teams wanting some structure without overhead",
    ],
    options: [
      "Alpine.js (15KB) - Vue-like directives",
      "Preact (3KB) - React-compatible",
      "Lit (5KB) - Web Components",
      "Petite-vue (6KB) - Vue subset",
    ],
    benefits: [
      "Small bundle sizes",
      "Familiar patterns",
      "Minimal setup",
      "Good documentation",
    ],
  },

  useFullFramework: {
    scenarios: [
      "Complex SPAs with lots of state",
      "Real-time collaborative apps",
      "Large teams needing standardization",
      "Apps requiring rich ecosystem",
    ],
    options: [
      "React - Largest ecosystem",
      "Vue - Gentle learning curve",
      "Svelte - Compiled, small bundles",
      "Solid - Maximum performance",
    ],
    benefits: [
      "Excellent tooling",
      "Large communities",
      "Proven patterns",
      "Rich ecosystem",
    ],
    tradeoffs: [
      "Higher complexity",
      "More dependencies",
      "Steeper learning curve",
      "Framework lock-in",
    ],
  },
};

Minimalist JavaScript in Practice

Example 1: Interactive UI Without Framework

// Building reactive UI with vanilla JavaScript (2025 approach)

// Simple state management
class Store {
  constructor(initialState) {
    this.state = initialState;
    this.listeners = new Set();
  }

  getState() {
    return this.state;
  }

  setState(updates) {
    this.state = { ...this.state, ...updates };
    this.listeners.forEach(listener => listener(this.state));
  }

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
}

// Example: Todo App
const todoStore = new Store({
  todos: [],
  filter: 'all', // all, active, completed
});

// DOM helpers
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];

// Render function
function renderTodos() {
  const { todos, filter } = todoStore.getState();

  const filtered = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });

  $('#todo-list').innerHTML = filtered
    .map(todo => `
      <li class="${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
        <input
          type="checkbox"
          ${todo.completed ? 'checked' : ''}
          onchange="toggleTodo('${todo.id}')"
        >
        <span>${todo.text}</span>
        <button onclick="deleteTodo('${todo.id}')">Delete</button>
      </li>
    `)
    .join('');

  $('#todo-count').textContent =
    `${todos.filter(t => !t.completed).length} items left`;
}

// Actions
window.addTodo = (text) => {
  const { todos } = todoStore.getState();
  todoStore.setState({
    todos: [...todos, {
      id: crypto.randomUUID(),
      text,
      completed: false
    }]
  });
};

window.toggleTodo = (id) => {
  const { todos } = todoStore.getState();
  todoStore.setState({
    todos: todos.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    )
  });
};

window.deleteTodo = (id) => {
  const { todos } = todoStore.getState();
  todoStore.setState({
    todos: todos.filter(t => t.id !== id)
  });
};

window.setFilter = (filter) => {
  todoStore.setState({ filter });
};

// Initialize
todoStore.subscribe(renderTodos);

// Handle form submission
$('#todo-form').addEventListener('submit', (e) => {
  e.preventDefault();
  const input = $('#todo-input');
  if (input.value.trim()) {
    addTodo(input.value.trim());
    input.value = '';
  }
});

// Total lines: ~80
// Dependencies: 0
// Bundle size: ~2KB
// Build step: None
// Framework: None

Example 2: Modern Vanilla JS with Web Components

// Building reusable components without framework

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  static get observedAttributes() {
    return ['user-id'];
  }

  connectedCallback() {
    this.render();
    this.loadUser();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'user-id' && oldValue !== newValue) {
      this.loadUser();
    }
  }

  async loadUser() {
    const userId = this.getAttribute('user-id');
    if (!userId) return;

    try {
      const response = await fetch(`/api/users/${userId}`);
      const user = await response.json();
      this.user = user;
      this.render();
    } catch (error) {
      this.renderError(error);
    }
  }

  render() {
    const { user } = this;

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ddd;
          border-radius: 8px;
          padding: 1rem;
          background: white;
        }

        .avatar {
          width: 64px;
          height: 64px;
          border-radius: 50%;
        }

        .name {
          font-size: 1.2rem;
          font-weight: bold;
          margin: 0.5rem 0;
        }

        .loading {
          color: #666;
        }
      </style>

      ${user ? `
        <img class="avatar" src="${user.avatar}" alt="${user.name}">
        <h3 class="name">${user.name}</h3>
        <p>${user.email}</p>
        <p>${user.bio}</p>
      ` : `
        <div class="loading">Loading user...</div>
      `}
    `;
  }

  renderError(error) {
    this.shadowRoot.innerHTML = `
      <div style="color: red;">
        Error loading user: ${error.message}
      </div>
    `;
  }
}

// Register the component
customElements.define('user-card', UserCard);

// Usage in HTML:
// <user-card user-id="123"></user-card>

// Benefits:
// - Native browser API (works everywhere)
// - Scoped CSS (shadow DOM)
// - Reusable across any framework
// - No build step required
// - Lifecycle hooks built-in

Example 3: Alpine.js for Light Interactivity

<!-- Alpine.js: Vue-like directives with 15KB -->

<!DOCTYPE html>
<html>
<head>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
</head>
<body>
  <!-- Counter component -->
  <div x-data="{ count: 0 }">
    <h2>Counter: <span x-text="count"></span></h2>
    <button @click="count++">Increment</button>
    <button @click="count--">Decrement</button>
    <button @click="count = 0">Reset</button>
  </div>

  <!-- Search with API -->
  <div
    x-data="{
      query: '',
      results: [],
      loading: false,

      async search() {
        if (!this.query) {
          this.results = [];
          return;
        }

        this.loading = true;
        try {
          const res = await fetch(`/api/search?q=${this.query}`);
          this.results = await res.json();
        } finally {
          this.loading = false;
        }
      }
    }"
  >
    <input
      type="text"
      x-model="query"
      @input.debounce.300ms="search()"
      placeholder="Search..."
    >

    <div x-show="loading">Searching...</div>

    <ul>
      <template x-for="result in results" :key="result.id">
        <li x-text="result.title"></li>
      </template>
    </ul>
  </div>

  <!-- Modal -->
  <div x-data="{ open: false }">
    <button @click="open = true">Open Modal</button>

    <div
      x-show="open"
      x-transition
      @click.away="open = false"
      style="position: fixed; inset: 0; background: rgba(0,0,0,0.5);"
    >
      <div style="background: white; margin: 50px auto; padding: 2rem; max-width: 500px;">
        <h2>Modal Content</h2>
        <p>Click outside to close</p>
        <button @click="open = false">Close</button>
      </div>
    </div>
  </div>
</body>
</html>

<!--
Benefits:
- No build step
- Familiar syntax (like Vue)
- Great for sprinkling interactivity
- Tiny bundle (15KB)
- Easy to learn in 30 minutes
-->

Strategic Framework Selection

The Decision Matrix

// How to choose the right approach for your project

const frameworkDecisionMatrix = {
  questions: [
    {
      question: "What's the project lifespan?",
      answers: {
        shortTerm: "Favor simplicity - vanilla JS or Alpine",
        longTerm: "Consider framework for maintainability",
      },
    },
    {
      question: "What's the team size?",
      answers: {
        solo: "Use what you know best, minimize complexity",
        small: "Lightweight framework or vanilla with good patterns",
        large: "Standardize on established framework",
      },
    },
    {
      question: "How complex is the state?",
      answers: {
        simple: "Local state in vanilla JS sufficient",
        moderate: "Light state library (Zustand, Jotai)",
        complex: "Full state management (Redux, Pinia)",
      },
    },
    {
      question: "What's the performance budget?",
      answers: {
        strict: "Vanilla JS or Preact/Svelte",
        moderate: "Most modern frameworks fine",
        relaxed: "Any framework",
      },
    },
    {
      question: "Is SEO critical?",
      answers: {
        yes: "SSR framework (Next, Nuxt, SvelteKit, Astro)",
        no: "SPA framework or vanilla fine",
      },
    },
  ],

  recommendations: {
    landingPage: {
      choice: "Vanilla HTML + CSS + minimal JS (or Alpine)",
      reason: "SEO critical, content-heavy, minimal interactivity",
      bundle: "< 20KB",
    },

    blogSite: {
      choice: "Astro or 11ty",
      reason: "Static content, fast builds, minimal JS",
      bundle: "< 50KB per page",
    },

    dashboard: {
      choice: "Vanilla JS with good patterns OR lightweight framework",
      reason: "No SEO needs, moderate complexity",
      bundle: "< 100KB",
    },

    socialMedia: {
      choice: "React/Vue/Svelte with meta-framework",
      reason: "Complex state, real-time updates, rich interactions",
      bundle: "200-400KB acceptable",
    },

    ecommerce: {
      choice: "Next.js/Nuxt/SvelteKit",
      reason: "SEO critical + dynamic features",
      bundle: "100-250KB",
    },
  },
};

Hybrid Approach: Best of Both Worlds

// Strategy: Use the right tool for each part of your app

const hybridStrategy = {
  concept: "Multi-page application with islands of interactivity",

  approach: {
    serverRendered: {
      what: "HTML generated on server",
      howToGenerate: [
        "SSR framework (Next, Nuxt, Remix)",
        "Static site generator (Astro, 11ty)",
        "Traditional server (Express + templates)",
      ],
      benefits: ["Fast FCP", "SEO friendly", "Works without JS"],
    },

    clientEnhancements: {
      what: "Add interactivity where needed",
      techniques: [
        "Alpine.js for simple interactions",
        "Web Components for complex widgets",
        "Lazy load React/Vue for rich sections",
      ],
      benefits: ["Progressive enhancement", "Minimal JS budget", "Best UX"],
    },
  },

  example: {
    blogPost: {
      structure: "Server-rendered HTML (Astro/11ty)",
      interactions: {
        commentSection: "Web Component or Alpine.js",
        shareButtons: "Vanilla JS",
        codeHighlighting: "Static at build time (Prism/Shiki)",
        darkModeToggle: "Vanilla JS + localStorage",
      },
      totalJS: "~25KB",
    },

    ecommerce: {
      structure: "Next.js SSR",
      interactions: {
        productCatalog: "Server-rendered + vanilla filters",
        cart: "React (complex state needed)",
        checkout: "React (complex validation)",
        productPage: "Server-rendered + Alpine for image gallery",
      },
      totalJS: "150KB (only load React where needed)",
    },
  },
};

Practical Tips for Minimalist Development

Modern Vanilla JS Patterns

// 2025 patterns that make vanilla JS productive

// 1. Template literals for rendering
function render(element, template) {
  element.innerHTML = template;
}

render(document.getElementById('app'), `
  <header>
    <h1>My App</h1>
  </header>
  <main>
    <p>Content here</p>
  </main>
`);

// 2. Event delegation (efficient event handling)
document.addEventListener('click', (e) => {
  // Handle delete buttons
  if (e.target.matches('[data-action="delete"]')) {
    const id = e.target.dataset.id;
    deleteItem(id);
  }

  // Handle edit buttons
  if (e.target.matches('[data-action="edit"]')) {
    const id = e.target.dataset.id;
    editItem(id);
  }
});

// 3. Proxy for reactive state
function createReactive(target, onChange) {
  return new Proxy(target, {
    set(obj, prop, value) {
      const oldValue = obj[prop];
      obj[prop] = value;
      if (oldValue !== value) {
        onChange(prop, value, oldValue);
      }
      return true;
    }
  });
}

const state = createReactive({ count: 0 }, (prop, value) => {
  console.log(`${prop} changed to ${value}`);
  render(); // Re-render on change
});

// 4. Custom events for component communication
class ComponentA extends HTMLElement {
  connectedCallback() {
    this.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('data-selected', {
        detail: { id: 123 },
        bubbles: true,
        composed: true
      }));
    });
  }
}

// Listen anywhere in the tree
document.addEventListener('data-selected', (e) => {
  console.log('Selected:', e.detail.id);
});

// 5. Async data loading pattern
class DataLoader {
  constructor(fetchFn) {
    this.fetchFn = fetchFn;
    this.cache = new Map();
  }

  async get(key) {
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    const data = await this.fetchFn(key);
    this.cache.set(key, data);
    return data;
  }

  invalidate(key) {
    this.cache.delete(key);
  }
}

const userLoader = new DataLoader((id) =>
  fetch(`/api/users/${id}`).then(r => r.json())
);

Tools That Help Minimalist Development

const minimalistToolbox = {
  noBuildNeeded: {
    importMaps: {
      description: "Import npm packages directly in browser",
      example: `
        <script type="importmap">
        {
          "imports": {
            "lit": "https://cdn.jsdelivr.net/npm/lit@3/+esm"
          }
        }
        </script>
        <script type="module">
          import { html, render } from 'lit';
        </script>
      `,
      support: "All modern browsers",
    },

    esModules: {
      description: "Native browser module support",
      example: `
        <script type="module" src="/js/app.js"></script>
      `,
      benefits: ["No bundler needed", "Native code splitting", "Tree shaking"],
    },
  },

  minimalBuild: {
    vite: {
      description: "Fast build tool with minimal config",
      setup: "npm create vite@latest",
      config: "Usually < 20 lines",
    },

    esbuild: {
      description: "Extremely fast bundler",
      usage: "esbuild app.js --bundle --outfile=out.js",
      speed: "10-100x faster than webpack",
    },
  },

  lightLibraries: {
    alpine: { size: "15KB", use: "Vue-like directives" },
    preact: { size: "3KB", use: "React alternative" },
    lit: { size: "5KB", use: "Web Components" },
    petiteVue: { size: "6KB", use: "Vue subset" },
    zustand: { size: "1KB", use: "State management" },
  },

  nativePlatformAPIs: {
    useInsteadOfLibraries: [
      "Fetch API (no axios needed)",
      "FormData (form handling)",
      "URLSearchParams (query strings)",
      "Intersection Observer (lazy loading)",
      "Web Animations API (animations)",
      "CSS Grid/Flexbox (no layout library needed)",
      "CSS Custom Properties (theming)",
      "LocalStorage/IndexedDB (client storage)",
    ],
  },
};

Conclusion: Embrace Simplicity

Framework fatigue is real, but the solution isn't to abandon modern web development—it's to be intentional about complexity.

Key takeaways:

  • Question defaults: Do you really need that framework?
  • Start simple: Add complexity only when justified
  • Learn fundamentals: JavaScript, DOM APIs, CSS
  • Embrace platform: Modern browsers are incredibly capable
  • Choose strategically: Right tool for each part of your app

The minimalist developer in 2025:

  • Understands fundamentals deeply
  • Uses frameworks strategically, not by default
  • Values maintenance over initial velocity
  • Writes code that lasts
  • Optimizes for simplicity and performance

Remember: The best code is code you don't have to write. The best dependency is the one you don't have.

Want to master modern JavaScript fundamentals? Check out: JavaScript Guide from Zero

Let's go!

📚 Master JavaScript Fundamentals

Minimalist development requires strong fundamentals. Understanding vanilla JavaScript deeply makes you effective whether you use frameworks or not.

Complete Study Material

Learn JavaScript the right way - from basics to advanced patterns:

Investment options:

  • 3x $34.54 BRL on credit card
  • or $97.90 BRL cash

👉 Check out the JavaScript Guide

💡 Focus on fundamentals that work with or without frameworks

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments