Home / books / backup / building-with-larc-original-20251226 / chapter-02-philosophy

The Philosophy of LARC

Introduction: Why Another Approach?

If you've been developing web applications for any length of time, you've probably noticed that the complexity keeps climbing. Each year brings new tools, new frameworks, and new "best practices" that somehow require even more configuration files, build steps, and abstract concepts to master.

But here's the uncomfortable truth: most of this complexity isn't solving your actual problems. It's solving problems created by previous layers of abstraction.

LARC takes a different approach. Instead of adding another layer of abstraction on top of the existing stack, it asks a more fundamental question: What if we could build modern web applications using the platform itself?

This chapter explores the philosophy behind LARC—the "why" that drives every design decision. Understanding this philosophy will help you use LARC more effectively and make better architectural decisions in your own projects.

Build Tool Fatigue and the Web Components Promise

The Real Problem: Developer Onboarding Overhead

Let's be honest about what actually drove the creation of LARC: build tool fatigue.

After years of watching developers spend more time configuring webpack, fighting with Babel, debugging TypeScript configs, and learning complex build pipelines than actually building features, it became clear that something was fundamentally wrong. New team members would join a project and spend their first week (or month!) learning the build system, understanding the toolchain, and navigating the maze of configuration files—all before they could write a single line of actual application code.

The barrier to entry had become absurd:

# A typical modern project setup
npm install
# Wait 10 minutes
# Install 1,200+ dependencies
# 400MB of node_modules

# Then fight with:
- webpack.config.js (200 lines)
- babel.config.js
- tsconfig.json
- .eslintrc.js
- postcss.config.js
- vite.config.js
- And dozens more...

This isn't what the web was supposed to be. The web platform itself requires none of this. You can write an HTML file, open it in a browser, and it works. So why did we accept all this complexity?

The Web Components Disappointment

Around the same time, Web Components promised to solve the reusability problem. The pitch was compelling: write a component once, use it anywhere. No framework lock-in. True portability. Native browser support. It sounded perfect.

But the reality was disappointing. Web Components solved the technical problem of creating custom elements, but they didn't solve the practical problem of building real applications. You still needed:

  • A way to manage state across components
  • A way for components to communicate
  • A way to handle data fetching and updates
  • Build tools (ironically) for anything non-trivial
The most frustrating part was this: every web component I built ended up tightly coupled to its current context anyway. A "user-profile" component needed direct access to the user object. A "product-card" needed specific methods from a parent component. A "notification-list" needed to import the notification service directly.
// This felt like defeat
class UserProfile extends HTMLElement {
  connectedCallback() {
    // Tightly coupled to global state
    const user = window.appState.user;

    // Tightly coupled to specific API
    this.api = window.userService;

    // Can't reuse this component in another project
    // because it depends on these specific globals
    this.render(user);
  }
}

What was the point? Web Components were supposed to be reusable, but I was building components that were just as tightly coupled as any framework component—except now with extra steps of abstraction. The technology gave us encapsulation, but it didn't give us independence.

It felt like using a more verbose syntax to achieve the same result. Why write a Custom Element if it can't actually be portable? Why bother with the Web Components API if you still need to wire everything together manually with brittle global dependencies?

Web Components gave us the syntax for reusable components, but not the architecture for building with them. They became yet another piece that needed framework scaffolding around them to be useful.

The PAN Experiment: How Far Can We Go?

LARC started as a simple experiment with the PAN (Page Area Network) concept—a message bus for browser components inspired by MQTT and the Actor model. The initial question was straightforward: "What if components could communicate through messages instead of direct coupling?"

Of course, I knew about the pub/sub pattern. I knew that web components could technically communicate via postMessage() or BroadcastChannel. But here's the thing: both of those APIs are low-level primitives. They give you the mechanism for sending messages, but not the architecture for organizing them.

With postMessage(), you'd write code like this:

// Sender
window.postMessage({ type: 'USER_LOGIN', payload: user }, '*');

// Receiver
window.addEventListener('message', (event) => {
  if (event.data.type === 'USER_LOGIN') {
    handleLogin(event.data.payload);
  }
});

And with BroadcastChannel:

// Sender
const channel = new BroadcastChannel('app-events');
channel.postMessage({ type: 'USER_LOGIN', user: user });

// Receiver
const channel = new BroadcastChannel('app-events');
channel.onmessage = (event) => {
  if (event.data.type === 'USER_LOGIN') {
    handleLogin(event.data.user);
  }
};

Both approaches have the same problem: every project rolls their own tightly coupled message format. You're back to the same coupling issues, just at a different level. Instead of coupling to window.appState, you're coupling to a specific message structure: { type: 'USER_LOGIN', payload: ... } vs { type: 'USER_LOGIN', user: ... }. Different projects would have different conventions, different payload shapes, different type naming schemes.

I saw this pattern repeated everywhere. Developers were independently creating custom messaging buses on top of postMessage and BroadcastChannel—each slightly different, each solving the same problems in slightly different ways. Everyone was building their own topic routing, their own message envelope format, their own subscription management.

It struck me: there should be a well-defined message standard. Not just "send messages," but a consistent format for:

  • Topic-based routing: user.login not { type: 'USER_LOGIN' }
  • Message envelopes: Consistent structure with data, metadata, timestamps
  • Subscription patterns: Wildcards like user. or .login
  • Retained messages: State that persists for late subscribers
  • Lifecycle management: Automatic cleanup when components disconnect
The web had given us the transport layer (BroadcastChannel), but we needed an application layer—a protocol that components could depend on without coupling to specific implementations.

That's when PAN moved from "let's try message passing" to "let's define a standard."

The moment this clicked was transformative. Instead of:

// Before: Tightly coupled
class UserProfile extends HTMLElement {
  connectedCallback() {
    const user = window.appState.user; // Coupled to specific global
    this.render(user);
  }
}

Components could do this:

// After: Loosely coupled through messages
class UserProfile extends HTMLElement {
  connectedCallback() {
    // Subscribe to a topic - any component can publish to it
    panClient.subscribe('user.profile', ({ data }) => {
      this.render(data);
    });

    // Request current data
    panClient.publish('user.profile.request');
  }
}

Now the component doesn't know where the user data comes from. It doesn't import anything. It doesn't depend on specific globals. It just subscribes to a topic. This component can be dropped into any project that has a PAN bus—different backend, different state management, different everything. As long as something publishes to 'user.profile', this component works.

This was the reusability promise that Web Components couldn't deliver alone. The PAN bus provided the missing piece: a standard way for components to communicate without coupling.

But that experiment led to a more interesting question: "How far can we go without any external, heavy, locked-in framework?"

Not from an anti-framework ideology—frameworks solve real problems and have their place. But from a pragmatic curiosity: the web platform has matured dramatically over the past decade. The problems React and its contemporaries solved 15 years ago—managing DOM updates, providing component models, handling events, supporting modern JavaScript—have largely been addressed by open standards now:

  • Custom Elements provide a native component model
  • ES Modules provide native code organization
  • Shadow DOM provides style encapsulation
  • JavaScript itself now has classes, async/await, destructuring, template literals
  • CSS has custom properties, grid, flexbox, container queries
  • Fetch API handles HTTP requests
  • BroadcastChannel enables cross-context messaging
The question became: if we use these standards directly, without transpilation, without heavy frameworks, can we build real applications that are actually simpler to understand and maintain?

The Build System Burden

Here's what really pushed the experiment forward: watching talented developers struggle not with code logic, not with algorithms, not with architecture—but with build configuration.

Consider this scenario (repeated countless times):

Developer: "I need to add a simple feature—just fetch some data and display it." Traditional Framework Workflow (React/Vue/Angular/etc.):
  • Pull latest code
  • Install/update dependencies (because the lockfile changed… again)
  • Create/update component file
  • Write fetch logic
  • Update state/store/actions
  • Update template/JSX
  • Update routing if needed
  • Update types/interfaces
  • Fix lint errors
  • Fix type errors
  • Start dev server (or wait for it to restart)
  • Wait for bundler to rebuild (don't forget about the source maps!)
  • Debug through layers of framework abstractions
  • Update tests and mocks
  • Commit changes
  • Push branch
  • Wait for CI: transpile, bundle, test, lint, type-check
  • Fix config/environment differences CI complains about
  • Merge when green
  • Build pipeline runs again for production artifacts
  • Deploy artifacts to server
  • Verify it works in production
  • Time spent: 2 hours Time actually coding: 15 minutes

    This is backwards. The tools should be invisible, not the primary challenge. Let's compare with the LARC workflow:

    LARC Workflow
  • Pull latest code
  • Open component or create a new one
  • Write fetch logic using standard fetch()
  • Drop the results into the DOM (template literal, innerHTML, whatever fits)
  • Refresh browser tab
  • Debug directly in the browser with no abstraction layer
  • Commit + push
  • CI runs lint/tests (no build pipeline)
  • Deploy static files
  • Done
  • That’s it. No bundler. No transpiler. No dev server. No JSX. No toolchain waiting room.

    What LARC Discovered

    Through the PAN experiment and building real applications without framework dependencies, several insights emerged:

  • Modern browsers are incredibly capable
  • You don't need JSX—template literals work great:

    render() {
      this.innerHTML = `
        <div class="card">
          <h2>${this.title}</h2>
          <p>${this.description}</p>
        </div>
      `;
    }

    You don't need a virtual DOM—the real DOM is fast enough for most use cases.

    You don't need Babel—browsers support modern JavaScript features natively.

  • Message-passing eliminates coupling
  • Components that communicate through messages instead of direct imports can truly be reused anywhere. They don't know about each other. They don't depend on each other. They just publish and subscribe to topics.

  • Build-free development is liberating
  • Edit a file. Refresh the browser. See the change instantly. No waiting. No watching. No hot module replacement complexity. Just immediate feedback.

    This dramatically lowers the barrier to entry—new developers can start contributing immediately.

  • Standards are more stable than frameworks
  • Web platform APIs evolve slowly and maintain backward compatibility. Code written with Custom Elements five years ago still works today. The same can't always be said for framework-specific code.

    The Philosophy That Emerged

    LARC's philosophy crystallized from these experiments:

    Use the platform. The web has matured. The standards are good. Build on them directly instead of abstracting them away. Optimize for understanding. Code that uses standard APIs is easier to understand than code that uses framework-specific abstractions. New developers already know HTML, CSS, and JavaScript—they don't need to learn a framework's mental model first. Make builds optional. Use builds for optimization in production if you need them, but don't require them for development. Let developers work directly with the code they write. Enable true portability. Components that depend only on web standards and a lightweight message bus can work in any project, with any stack, indefinitely.

    This isn't about being anti-framework. React, Vue, Svelte, and others are excellent tools that solve real problems. But for many projects, the web platform itself—combined with a simple communication mechanism—is sufficient. And when it is, why take on the complexity?

    Message-Passing Architecture: Learning from Distributed Systems

    The Inspiration: Actor Model and Message Queues

    LARC's solution comes from distributed systems theory. When building systems with multiple independent processes, you don't use shared state—you use message passing. Each process maintains its own state and communicates with other processes by sending messages.

    This pattern appears throughout computing:

    • Operating systems: Processes communicate via message queues
    • Actor model: Erlang, Akka, and Orleans use message passing for concurrency
    • Microservices: Services communicate via HTTP, message queues, or event streams
    • MQTT: IoT devices coordinate through pub/sub messaging
    • Event-driven architecture: Systems react to events without direct coupling
    These patterns work because they solve fundamental problems:
  • Decoupling: Components don't need to know about each other
  • Scalability: Add components without modifying existing ones
  • Resilience: Failures are isolated and don't cascade
  • Flexibility: Swap implementations without changing interfaces
  • LARC brings this pattern to the browser with the PAN (Page Area Network) bus—a publish/subscribe message system that allows components to communicate without knowing about each other.

    The PAN Bus: Pub/Sub for Components

    Here's how state management looks with the PAN bus:

    // Publishing a message (any component can do this)
    panClient.publish('cart.item.add', {
      id: 'product-123',
      name: 'Coffee Mug',
      price: 12.99,
      quantity: 1
    }, { retain: true });
    
    // Subscribing to messages (any component can listen)
    panClient.subscribe('cart.item.add', ({ data }) => {
      console.log('Item added to cart:', data);
      updateCartDisplay();
    });
    
    // Multiple components can react to the same message
    panClient.subscribe('cart.item.add', ({ data }) => {
      // Update cart count badge
      document.getElementById('cart-count').textContent =
        getCartItems().length;
    });
    
    panClient.subscribe('cart.item.add', ({ data }) => {
      // Show notification
      showNotification(`${data.name} added to cart`);
    });

    No action creators. No reducers. No connect() functions. Just messages flowing through the system.

    Topic-Based Routing: Organization Without Central Control

    The PAN bus uses topic-based routing, similar to MQTT or RabbitMQ. Topics are hierarchical strings separated by dots:

    user.profile.update
    cart.item.add
    cart.item.remove
    cart.checkout.start
    inventory.product.update
    ui.modal.open
    ui.sidebar.toggle

    Components can subscribe to specific topics or use wildcards:

    // Subscribe to specific topic
    panClient.subscribe('cart.item.add', handler);
    
    // Subscribe to all cart events
    panClient.subscribe('cart.*', handler);
    
    // Subscribe to all add events
    panClient.subscribe('*.item.add', handler);
    
    // Subscribe to everything
    panClient.subscribe('*', handler);

    This creates natural organization:

    • Namespace separation: Different domains have different topic prefixes
    • Granular subscriptions: Subscribe only to what you need
    • Discoverability: Topic names describe what they do
    • No central registry: Components define their own topics

    Retained Messages: State Without Stores

    Traditional message buses are ephemeral—messages are delivered once and then disappear. But web applications need state: when a new component loads, it needs the current state, not just future updates.

    LARC solves this with retained messages—the last message published to a topic can be stored and delivered to new subscribers:

    // Publish with retention
    panClient.publish('user.preferences.theme', 'dark', { retain: true });
    
    // Later, a new component subscribes...
    panClient.subscribe('user.preferences.theme', ({ data }) => {
      // Immediately receives 'dark' even though it subscribed after publication
      applyTheme(data);
    });

    This provides state management without a central store:

    • Current state: Retrieved by subscribing to retained topics
    • State updates: Published as new messages
    • No special API: Same subscribe() method works for both
    • Automatic synchronization: New components automatically get current state
    Think of retained messages as "stateful topics"—each topic can hold exactly one value, similar to a key in a key-value store, but with pub/sub semantics.

    Benefits of Message-Passing

    Why is this better than centralized state management?

  • Zero coupling between components
  • Components don't import each other. They don't know each other exist. They just publish and subscribe to topics. This means:

    • Add components freely: New components can subscribe to existing topics
    • Remove components safely: Unsubscribing doesn't break other components
    • Test in isolation: Mock the bus, not the entire application state
    • Reuse anywhere: Components work in any application with a PAN bus
  • Progressive complexity
  • Start simple and add sophistication only when needed:

    // Simple: Direct message handling
    panClient.subscribe('cart.item.add', ({ data }) => {
      items.push(data);
      render();
    });
    
    // Advanced: Add validation
    panClient.subscribe('cart.item.add', ({ data }) => {
      if (validateItem(data)) {
        items.push(data);
        render();
      } else {
        panClient.publish('cart.error', 'Invalid item');
      }
    });
    
    // Sophisticated: Add persistence
    panClient.subscribe('cart.item.add', async ({ data }) => {
      if (validateItem(data)) {
        items.push(data);
        await saveToLocalStorage(items);
        panClient.publish('cart.synced', items);
        render();
      }
    });

    No refactoring required. Just add features incrementally.

  • Natural debugging
  • Every message flows through the bus. Want to debug state changes? Subscribe to all topics:

    // Development debugging
    panClient.subscribe('*', ({ topic, data, meta }) => {
      console.log(`[${topic}]`, data, meta);
    });

    Want to trace a specific feature? Subscribe to its topics:

    // Track all cart operations
    panClient.subscribe('cart.*', ({ topic, data }) => {
      console.log('Cart event:', topic, data);
    });

    Compare this to stepping through Redux reducers or tracing Vue reactivity. Message-passing makes data flow explicit and observable.

  • Multi-component coordination
  • Traditional state management struggles with coordinating multiple components:

    // Redux: Components must import actions and know about each other
    import { openModal } from './modalActions';
    import { pauseVideo } from './videoActions';
    import { saveFormData } from './formActions';
    
    function handleSaveAndClose() {
      dispatch(saveFormData(data));
      dispatch(pauseVideo());
      dispatch(openModal('confirmation'));
    }

    With message-passing, components coordinate through messages:

    // Publisher doesn't know who's listening
    panClient.publish('form.save.request', formData);
    
    // Multiple components react independently
    panClient.subscribe('form.save.request', ({ data }) => {
      // Form component saves data
      saveToDatabase(data);
    });
    
    panClient.subscribe('form.save.request', () => {
      // Video component pauses playback
      pauseVideo();
    });
    
    panClient.subscribe('form.save.request', () => {
      // Modal component shows confirmation
      showModal('confirmation');
    });

    Each component handles its own concerns. No central coordinator needed.

    DOM-Native Communication Principles

    Leveraging Existing Standards

    LARC doesn't invent new communication patterns—it leverages patterns already present in the DOM:

  • Events: The DOM uses events for component interaction
  • Attributes: Components configure via attributes
  • Properties: JavaScript interfaces use properties
  • Custom Elements: The browser provides a component system
  • The PAN bus extends these patterns to handle application-level communication:

    <!-- DOM events: Local communication -->
    <button onclick="handleClick()">Click me</button>
    
    <!-- PAN messages: Application-level communication -->
    <pan-button topic="ui.action.click">Click me</pan-button>

    This creates a natural mental model:

    • DOM events = Component-to-parent communication
    • PAN messages = Component-to-application communication
    • Attributes = Configuration
    • Properties = JavaScript API

    BroadcastChannel: The Foundation

    Under the hood, LARC can use the browser's BroadcastChannel API—a standard way to communicate between browser contexts (tabs, windows, iframes, workers):

    // Native BroadcastChannel
    const channel = new BroadcastChannel('my-app');
    channel.postMessage({ type: 'update', data: 'value' });
    channel.onmessage = (event) => {
      console.log('Received:', event.data);
    };

    The PAN bus builds on this foundation but adds:

    • Topic-based routing with wildcards
    • Retained messages for state
    • Message metadata and timestamps
    • Synchronous and asynchronous delivery
    • Type safety and validation (optional)
    By building on web standards, LARC remains simple, debuggable, and future-proof.

    Custom Elements: Native Components

    LARC components are standard Custom Elements:

    class PanCard extends HTMLElement {
      connectedCallback() {
        this.render();
    
        // Subscribe to theme changes
        this.subscription = panClient.subscribe('theme.change', ({ data }) => {
          this.applyTheme(data);
        });
      }
    
      disconnectedCallback() {
        // Clean up subscriptions
        this.subscription.unsubscribe();
      }
    
      render() {
        this.innerHTML = `
          <div class="card">
            <slot></slot>
          </div>
        `;
      }
    }
    
    customElements.define('pan-card', PanCard);

    No framework required. Just standard Web Components that happen to communicate via the PAN bus.

    Zero-Dependency, Zero-Build Philosophy

    The Build Tool Problem

    Modern web development has become synonymous with build tools. Want to create a simple website? First, install Node.js, npm, webpack, Babel, and a dozen plugins. Wait for installation. Configure everything. Then finally write code.

    This creates several problems:

  • Barrier to entry
  • Beginners must learn the toolchain before they can learn web development:

    • What's webpack? What's Babel? What are these 1000 dependencies?
    • Why did the build fail? What's a source map?
    • How do I debug code that's been transpiled?
  • Maintenance burden
  • Build configurations break:

    • Dependency updates introduce breaking changes
    • Security vulnerabilities require constant updates
    • Build times increase as projects grow
    • Different team members have different tool versions
  • Slower iteration
  • Build steps add friction to development:

    • Edit code -> wait for rebuild -> refresh browser
    • Build errors interrupt flow
    • Debugging transpiled code is harder
    • Hot module replacement adds complexity
  • Framework lock-in
  • Each framework has its own tooling:

    • React uses Create React App (or Vite, or Next.js...)
    • Vue uses Vue CLI (or Vite...)
    • Angular uses Angular CLI
    • Svelte uses SvelteKit
    Switching frameworks means learning new tools.

    LARC's Solution: Direct Browser Execution

    LARC eliminates build requirements for development by using only features that browsers understand natively:

  • ES Modules
  • // Native imports work directly in browsers
    import { PanClient } from './pan-client.mjs';
    import './components/pan-card.mjs';

    No bundler required. The browser loads modules directly.

  • Standard JavaScript
  • // Write modern JavaScript that runs directly
    class PanCard extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
      }
    
      async connectedCallback() {
        const data = await fetch('/api/data');
        this.render(await data.json());
      }
    }

    No transpilation. Modern browsers support classes, async/await, destructuring, template literals, and other ES2015+ features.

  • CSS Custom Properties
  • /* Native CSS variables for theming */
    .card {
      background: var(--card-background, white);
      color: var(--card-color, black);
      border-radius: var(--card-radius, 8px);
    }

    No CSS-in-JS runtime. No styled-components overhead.

  • Web Components
  • // Native component system
    customElements.define('pan-card', PanCard);

    No framework runtime. No virtual DOM. Just browser primitives.

    The Development Experience

    Here's the entire setup for a LARC project:

    # 1. Create an HTML file
    cat > index.html << 'EOF'
    <!DOCTYPE html>
    <html>
    <head>
      <title>My App</title>
    </head>
    <body>
      <script type="module" src="/src/pan.mjs"></script>
      <pan-bus></pan-bus>
      <pan-card title="Hello">
        <p>World</p>
      </pan-card>
    </body>
    </html>
    EOF
    
    # 2. Start a static server
    python3 -m http.server 8000
    
    # 3. Open browser
    # http://localhost:8000
    
    # That's it. No npm install. No webpack config. No build step.

    Edit a file. Refresh the browser. See the change instantly.

    This isn't just simpler—it's faster. No build step means:

    • Instant feedback on changes
    • Debugging actual source code
    • No build cache to corrupt
    • No toolchain version conflicts

    But What About Production?

    The zero-build philosophy applies to development, not necessarily production. For production, you can (and often should) use build tools:

    # Production build with Vite (optional)
    vite build
    
    # Result: Minified, tree-shaken, optimized bundle

    But here's the key difference: builds are optional, not required.

    • Developing locally? No build needed.
    • Prototyping? No build needed.
    • Small sites? Deploy directly, no build needed.
    • Large applications? Add a build for optimization.
    The build is an optimization, not a requirement.

    Comparison: LARC vs. Traditional Tools

    Let's compare the development experience:

    React with Create React App:
    $ npx create-react-app my-app
    # Wait 5-10 minutes
    # Install 1,400+ dependencies
    # Project size: 350MB
    
    $ cd my-app
    $ npm start
    # Wait for build
    # Edit file
    # Wait for rebuild
    # See changes (2-5 seconds later)
    LARC:
    $ mkdir my-app
    $ cd my-app
    $ cat > index.html << 'EOF'
    <!DOCTYPE html>
    <html>
    <head>
      <script type="module" src="https://cdn.jsdelivr.net/npm/@larcjs/core@3.0.1/pan.mjs"></script>
    </head>
    <body>
      <pan-bus></pan-bus>
      <pan-card>Hello World</pan-card>
    </body>
    </html>
    EOF
    
    $ python3 -m http.server
    # Edit file
    # Refresh browser
    # See changes (instantly)

    One approach requires 350MB and 1,400 dependencies. The other requires an HTML file and a browser.

    Progressive Enhancement and Graceful Degradation

    Layering Functionality

    LARC embraces the web's fundamental principle: progressive enhancement. Start with a basic HTML structure that works, then enhance it with JavaScript.

    Level 0: Static HTML
    <article class="card">
      <h2>Product Name</h2>
      <p>Description of the product</p>
      <a href="/product/123">View Details</a>
    </article>

    Works everywhere. No JavaScript required. Search engines can index it. Screen readers can navigate it.

    Level 1: Basic Web Component
    <script type="module">
      class ProductCard extends HTMLElement {
        connectedCallback() {
          const product = JSON.parse(this.getAttribute('data-product'));
          this.innerHTML = `
            <article class="card">
              <h2>${product.name}</h2>
              <p>${product.description}</p>
              <a href="/product/${product.id}">View Details</a>
            </article>
          `;
        }
      }
      customElements.define('product-card', ProductCard);
    </script>
    
    <product-card data-product='{"name":"Product","description":"Description","id":"123"}'></product-card>

    Enhanced with JavaScript, but the content is still in the DOM. Still indexable. Still accessible.

    Level 2: PAN Integration
    class ProductCard extends HTMLElement {
      connectedCallback() {
        // Subscribe to product updates
        this.subscription = panClient.subscribe(
          `product.${this.productId}.*`,
          ({ data }) => this.update(data)
        );
    
        // Request current data
        panClient.publish(`product.${this.productId}.fetch`, null);
      }
    
      disconnectedCallback() {
        this.subscription.unsubscribe();
      }
    
      update(data) {
        this.render(data);
      }
    }

    Now the component participates in the application's message flow. It reacts to updates, coordinates with other components, and manages state.

    Level 3: Advanced Features
    class ProductCard extends HTMLElement {
      connectedCallback() {
        // Theme support
        panClient.subscribe('theme.change', ({ data }) => {
          this.applyTheme(data);
        });
    
        // Internationalization
        panClient.subscribe('locale.change', ({ data }) => {
          this.updateLocale(data);
        });
    
        // Real-time updates
        panClient.subscribe(`product.${this.productId}.update`, ({ data }) => {
          this.animateUpdate(data);
        });
    
        // Analytics
        this.addEventListener('click', () => {
          panClient.publish('analytics.event', {
            type: 'product-click',
            id: this.productId
          });
        });
      }
    }

    Each layer adds functionality without breaking previous layers. If JavaScript fails to load, the HTML still works. If the PAN bus isn't available, the component still renders.

    Graceful Degradation

    Progressive enhancement works in reverse too. As capabilities decrease, functionality gracefully degrades:

    class EnhancedComponent extends HTMLElement {
      connectedCallback() {
        // Check for Web Components support (already guaranteed if this runs)
    
        // Check for ES Modules support
        if ('noModule' in HTMLScriptElement.prototype) {
          this.enableModuleFeatures();
        }
    
        // Check for Shadow DOM support
        if (this.attachShadow) {
          this.attachShadow({ mode: 'open' });
          this.useShadowDOM = true;
        } else {
          // Fallback: Light DOM with scoped styles
          this.useShadowDOM = false;
        }
    
        // Check for BroadcastChannel (for cross-tab sync)
        if ('BroadcastChannel' in window) {
          this.enableCrossTabSync();
        }
    
        // Check for IndexedDB (for persistence)
        if ('indexedDB' in window) {
          this.enablePersistence();
        } else {
          // Fallback: localStorage
          this.enableBasicStorage();
        }
    
        this.render();
      }
    }

    The component adapts to available APIs. It doesn't require cutting-edge features—it enhances the experience when they're available.

    Browser Support Strategy

    LARC targets modern browsers (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+) that support:

    • Custom Elements v1
    • Shadow DOM v1
    • ES Modules
    • ES2020 features
    This covers 95%+ of global users. For the remaining 5%, you have options: Option 1: Polyfills

    Load polyfills for older browsers:

    <script src="https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-loader.js"></script>
    <script type="module" src="/src/app.mjs"></script>
    Option 2: Build Step

    For maximum compatibility, add a production build that transpiles to ES5:

    vite build --target es2015
    Option 3: Server-Side Rendering

    Render content on the server for browsers without JavaScript:

    // Node.js
    import { renderToString } from '@larcjs/ssr';
    const html = renderToString('<pan-card>Hello</pan-card>');

    But here's the key: these are optimizations, not requirements. Start with modern browsers and add compatibility only if needed.

    Comparison to Other Approaches

    LARC vs. Redux

    Redux:
    // Redux requires significant boilerplate
    
    // 1. Define action types
    const ADD_ITEM = 'ADD_ITEM';
    const REMOVE_ITEM = 'REMOVE_ITEM';
    const UPDATE_ITEM = 'UPDATE_ITEM';
    
    // 2. Create action creators
    const addItem = (item) => ({ type: ADD_ITEM, payload: item });
    const removeItem = (id) => ({ type: REMOVE_ITEM, payload: id });
    const updateItem = (id, data) => ({ type: UPDATE_ITEM, payload: { id, data } });
    
    // 3. Write reducer
    function itemsReducer(state = [], action) {
      switch (action.type) {
        case ADD_ITEM:
          return [...state, action.payload];
        case REMOVE_ITEM:
          return state.filter(item => item.id !== action.payload);
        case UPDATE_ITEM:
          return state.map(item =>
            item.id === action.payload.id
              ? { ...item, ...action.payload.data }
              : item
          );
        default:
          return state;
      }
    }
    
    // 4. Create store
    const store = createStore(combineReducers({ items: itemsReducer }));
    
    // 5. Connect components
    const mapStateToProps = (state) => ({ items: state.items });
    const mapDispatchToProps = { addItem, removeItem, updateItem };
    export default connect(mapStateToProps, mapDispatchToProps)(ItemList);
    LARC:
    // LARC uses message-passing - no boilerplate
    
    // Publish messages
    panClient.publish('items.add', item, { retain: true });
    panClient.publish('items.remove', id);
    panClient.publish('items.update', { id, data });
    
    // Subscribe in components
    panClient.subscribe('items.*', ({ topic, data }) => {
      if (topic.endsWith('.add')) {
        items.push(data);
      } else if (topic.endsWith('.remove')) {
        items = items.filter(item => item.id !== data);
      } else if (topic.endsWith('.update')) {
        items = items.map(item =>
          item.id === data.id ? { ...item, ...data.data } : item
        );
      }
      render();
    });
    Key Differences:

    | Redux | LARC | |---------------|----------| | Centralized store, reducers, actions, selectors | Distributed messages, topics, subscribers | | All state in one place | State distributed across components | | Components coupled to store | Components coupled only to topics | | Requires setup and configuration | Just publish and subscribe |

    When Redux is better:
    • Need single source of truth
    • Complex state transformations
    • Time-travel debugging is essential
    • Team is already trained in Redux
    When LARC is better:
    • Loosely coupled components
    • Progressive enhancement
    • Mix of frameworks
    • Simple message-driven logic

    LARC vs. Vuex

    Vuex:
    // Vuex uses a centralized store with mutations and actions
    
    // Define store
    const store = new Vuex.Store({
      state: {
        items: []
      },
      mutations: {
        ADD_ITEM(state, item) {
          state.items.push(item);
        },
        REMOVE_ITEM(state, id) {
          state.items = state.items.filter(item => item.id !== id);
        }
      },
      actions: {
        addItem({ commit }, item) {
          commit('ADD_ITEM', item);
        },
        async fetchItems({ commit }) {
          const items = await api.getItems();
          commit('SET_ITEMS', items);
        }
      },
      getters: {
        itemCount: state => state.items.length
      }
    });
    
    // Use in components
    export default {
      computed: {
        items() {
          return this.$store.state.items;
        },
        itemCount() {
          return this.$store.getters.itemCount;
        }
      },
      methods: {
        addItem(item) {
          this.$store.dispatch('addItem', item);
        }
      }
    };
    LARC:
    // LARC doesn't require store setup or mutations
    
    class ItemList extends HTMLElement {
      constructor() {
        super();
        this.items = [];
      }
    
      connectedCallback() {
        // Subscribe to item messages
        panClient.subscribe('items.add', ({ data }) => {
          this.items.push(data);
          this.render();
        });
    
        panClient.subscribe('items.remove', ({ data: id }) => {
          this.items = this.items.filter(item => item.id !== id);
          this.render();
        });
    
        // Request current items
        panClient.publish('items.fetch');
      }
    
      addItem(item) {
        panClient.publish('items.add', item, { retain: true });
      }
    }
    Key Differences:

    | Vuex | LARC | |---------------------------|-----------------------------------| | Mutations, actions, getters | Messages, topics, subscriptions | | Vue-specific | Framework-agnostic | | Centralized state | Distributed state |

    LARC vs. MobX

    MobX:
    // MobX uses observables and reactions
    
    import { observable, action, computed, makeObservable } from 'mobx';
    import { observer } from 'mobx-react';
    
    class ItemStore {
      items = [];
    
      constructor() {
        makeObservable(this, {
          items: observable,
          addItem: action,
          removeItem: action,
          itemCount: computed
        });
      }
    
      addItem(item) {
        this.items.push(item);
      }
    
      removeItem(id) {
        this.items = this.items.filter(item => item.id !== id);
      }
    
      get itemCount() {
        return this.items.length;
      }
    }
    
    const itemStore = new ItemStore();
    
    // Observer component
    const ItemList = observer(() => {
      return (
        <div>
          <p>Count: {itemStore.itemCount}</p>
          {itemStore.items.map(item => (
            <Item key={item.id} item={item} />
          ))}
        </div>
      );
    });
    LARC:
    // LARC uses explicit message passing
    
    class ItemList extends HTMLElement {
      constructor() {
        super();
        this.items = [];
      }
    
      connectedCallback() {
        panClient.subscribe('items.add', ({ data }) => {
          this.items.push(data);
          this.render();
        });
    
        panClient.subscribe('items.remove', ({ data: id }) => {
          this.items = this.items.filter(item => item.id !== id);
          this.render();
        });
    
        this.render();
      }
    
      render() {
        this.innerHTML = `
          <div>
            <p>Count: ${this.items.length}</p>
            ${this.items.map(item => `<item-element data-item='${JSON.stringify(item)}'></item-element>`).join('')}
          </div>
        `;
      }
    }
    Key Differences:

    | MobX | LARC | |---------------------------|---------------------------| | Automatic reactivity through proxies | Explicit message subscriptions | | Implicit dependencies | Explicit topic subscriptions | | Framework integration required | Framework-agnostic |

    When MobX is better:
    • Need automatic reactivity
    • Complex computed values
    • Fine-grained updates
    When LARC is better:
    • Want explicit data flow
    • Need component isolation
    • Cross-framework compatibility

    LARC vs. Context API (React)

    React Context:
    // Context requires provider setup and consumer components
    
    const ThemeContext = React.createContext('light');
    const UserContext = React.createContext(null);
    
    function App() {
      const [theme, setTheme] = useState('light');
      const [user, setUser] = useState(null);
    
      return (
        <ThemeContext.Provider value={{ theme, setTheme }}>
          <UserContext.Provider value={{ user, setUser }}>
            <Header />
            <Main />
            <Footer />
          </UserContext.Provider>
        </ThemeContext.Provider>
      );
    }
    
    function Header() {
      const { theme, setTheme } = useContext(ThemeContext);
      const { user } = useContext(UserContext);
    
      return (
        <header className={theme}>
          <h1>Hello, {user?.name}</h1>
          <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
            Toggle Theme
          </button>
        </header>
      );
    }
    LARC:
    <!-- No provider nesting required -->
    <script type="module" src="/src/pan.mjs"></script>
    <pan-bus></pan-bus>
    
    <pan-header></pan-header>
    <pan-main></pan-main>
    <pan-footer></pan-footer>
    
    <script type="module">
      // Components subscribe to topics
      class PanHeader extends HTMLElement {
        connectedCallback() {
          panClient.subscribe('theme.change', ({ data }) => {
            this.className = data;
          });
    
          panClient.subscribe('user.login', ({ data }) => {
            this.querySelector('h1').textContent = `Hello, ${data.name}`;
          });
    
          this.querySelector('button').addEventListener('click', () => {
            const current = this.className || 'light';
            const next = current === 'light' ? 'dark' : 'light';
            panClient.publish('theme.change', next, { retain: true });
          });
        }
      }
    
      customElements.define('pan-header', PanHeader);
    </script>
    Key Differences:

    | Context | LARC | |---------------------------|-----------------------| | Provider hierarchy, hook dependencies | Flat message bus | | React-specific | Framework-agnostic | | Requires wrapping components | Just subscribe to topics |

    Summary: Architectural Trade-offs

    | Feature | Redux | Vuex | MobX | Context | LARC | |---------|-------|------|------|---------|------| | Learning curve | Steep | Medium | Medium | Low | Low | | Boilerplate | High | Medium | Low | Low | Minimal | | Framework lock-in | No | Yes | No | Yes | No | | Testability | Excellent | Good | Good | Fair | Excellent | | DevTools | Excellent | Excellent | Good | Limited | Good | | Bundle size | ~8KB | ~3KB | ~16KB | 0KB | ~5KB | | Async handling | Middleware | Built-in | Built-in | Manual | Manual | | Type safety | Good | Fair | Excellent | Good | Optional | | Cross-framework | Possible | No | Possible | No | Yes | | Component coupling | Store | Store | Store | Context | Topics |

    *Requires framework integration libraries

    LARC's sweet spot:
    • You need loose coupling between components
    • You're mixing frameworks or using vanilla JS
    • You want simple, explicit data flow
    • You value progressive enhancement
    • You want minimal dependencies
    LARC's limitations:
    • No automatic reactivity (explicit subscriptions required)
    • No built-in transaction/rollback
    • No built-in middleware system (yet)
    • No official browser DevTools extension (yet)

    The Problems LARC Solves

    Let's look at concrete examples of problems LARC addresses:

    Problem 1: Reusing Components Across Projects

    Traditional approach:

    You build a beautiful data table component in React. Later, you want to use it in a Vue project. You have to:

  • Rewrite it in Vue
  • Maintain two codebases
  • Keep features in sync
  • Test both versions
  • Or you try to use a React component in Vue:

    // Awkward wrapper component
    import ReactDataTable from 'react-data-table';
    import { createApp } from 'vue-react-wrapper';
    
    export default {
      mounted() {
        createApp(ReactDataTable, this.$el, this.props);
      }
    };

    It works, but it's clunky. You're shipping both React and Vue runtimes.

    LARC approach:
    <!-- Works in any framework -->
    <pan-data-table
      data-topic="users.list"
      columns='["name", "email", "role"]'
      sortable
      filterable>
    </pan-data-table>

    The component is framework-agnostic. Use it in React, Vue, Angular, or vanilla JS. No wrapper needed. No duplicate runtimes.

    Problem 2: Coordinating Independent Features

    Traditional approach:

    You're building a dashboard. When the user logs in, you need to:

  • Update the header with username
  • Load user preferences
  • Fetch notifications
  • Start real-time updates
  • Track analytics event
  • In Redux, you'd dispatch an action and handle it in multiple reducers:

    // Login action triggers cascade of reducers
    dispatch(loginSuccess(user));
    
    // Multiple reducers react to LOGIN_SUCCESS
    function headerReducer(state, action) {
      if (action.type === LOGIN_SUCCESS) {
        return { ...state, user: action.payload };
      }
    }
    
    function preferencesReducer(state, action) {
      if (action.type === LOGIN_SUCCESS) {
        // Fetch preferences
      }
    }
    
    function notificationsReducer(state, action) {
      if (action.type === LOGIN_SUCCESS) {
        // Start polling
      }
    }
    
    // This couples all these features to the login action
    LARC approach:
    // Login component publishes one message
    panClient.publish('user.login', userData, { retain: true });
    
    // Each feature subscribes independently
    panClient.subscribe('user.login', ({ data }) => {
      updateHeader(data);
    });
    
    panClient.subscribe('user.login', async ({ data }) => {
      const prefs = await fetchPreferences(data.id);
      panClient.publish('user.preferences', prefs, { retain: true });
    });
    
    panClient.subscribe('user.login', ({ data }) => {
      startNotificationPolling(data.id);
    });
    
    panClient.subscribe('user.login', ({ data }) => {
      analytics.track('login', { userId: data.id });
    });

    No coupling. Features can be added or removed independently. Each feature handles its own logic.

    Problem 3: Testing Components in Isolation

    Traditional approach:

    Testing a Redux-connected component requires:

    import { Provider } from 'react-redux';
    import configureMockStore from 'redux-mock-store';
    import { render } from '@testing-library/react';
    
    const mockStore = configureMockStore();
    const store = mockStore({
      items: [],
      user: { name: 'Test' },
      theme: 'light'
    });
    
    test('renders item list', () => {
      render(
        <Provider store={store}>
          <ItemList />
        </Provider>
      );
      // Test assertions...
    });

    You have to:

  • Mock the entire store
  • Provide store structure
  • Wrap component in Provider
  • Understand Redux internals
  • LARC approach:
    import { mockPanClient } from '@larcjs/testing';
    
    test('renders item list', () => {
      const client = mockPanClient();
      const itemList = document.createElement('item-list');
      document.body.appendChild(itemList);
    
      // Publish test data
      client.publish('items.list', [
        { id: 1, name: 'Item 1' },
        { id: 2, name: 'Item 2' }
      ], { retain: true });
    
      // Test assertions on DOM...
    });

    Simpler. Mock the message bus, not the entire application state.

    Problem 4: Debugging Complex State Changes

    Traditional approach:

    Debugging a Redux app with multiple reducers and middleware:

    // Something changed, but where?
    // - Check Redux DevTools
    // - Find the action
    // - Trace through reducers
    // - Check middleware
    // - Examine normalized state
    // - Follow selector chains
    LARC approach:
    // Subscribe to all messages during development
    if (import.meta.env.DEV) {
      panClient.subscribe('*', ({ topic, data, meta }) => {
        console.log(`[${meta.timestamp}] ${topic}`, data);
      });
    }
    
    // Result: Clear log of every message
    // [1234567890] user.login { id: 123, name: 'Alice' }
    // [1234567891] user.preferences { theme: 'dark' }
    // [1234567892] cart.item.add { id: 456, name: 'Product' }

    Every state change is an explicit message. No hidden transformations. No mysterious updates. Just a log of events.

    Problem 5: Multi-Tab Synchronization

    Traditional approach:

    Synchronizing state across browser tabs is painful:

    // Tab 1: Update state
    localStorage.setItem('user', JSON.stringify(user));
    
    // Tab 2: Poll for changes
    setInterval(() => {
      const stored = localStorage.getItem('user');
      const user = JSON.parse(stored);
      updateState(user);
    }, 1000);
    
    // Or use storage events (but they're clunky)
    window.addEventListener('storage', (e) => {
      if (e.key === 'user') {
        updateState(JSON.parse(e.newValue));
      }
    });
    LARC approach:

    LARC can use BroadcastChannel for cross-tab communication:

    // Tab 1: Publish message
    panClient.publish('user.update', user, { retain: true });
    
    // Tab 2: Automatically receives message
    panClient.subscribe('user.update', ({ data }) => {
      updateState(data);
    });
    
    // No polling. No storage events. Just messages.

    Tabs stay synchronized automatically through the PAN bus.

    Conclusion: A Pragmatic Philosophy

    LARC's philosophy can be summarized in a few principles:

  • Build on standards, not abstractions — Use what browsers provide natively
  • Message-passing over shared state — Loose coupling through pub/sub
  • Zero-build development — Edit code and see results immediately
  • Progressive enhancement — Start simple, add complexity only when needed
  • Framework-agnostic — Components work everywhere
  • Explicit over implicit — Data flow should be obvious
  • This philosophy makes trade-offs:

    What you gain:
    • Simplicity
    • Portability
    • Debuggability
    • Fast iteration
    • Low barrier to entry
    What you give up:
    • Automatic reactivity
    • Framework-specific optimizations
    • Established ecosystem
    • Corporate backing
    Is LARC right for your project? Consider these questions:
    • Do you value simplicity over framework features?
    • Do you need components that work across frameworks?
    • Do you want fast iteration during development?
    • Are you comfortable with explicit data flow?
    • Do you prefer standards over abstractions?
    If you answered "yes" to most of these, LARC might be a good fit.

    In the next chapter, we'll explore the story of LARC—how it came to be, the design decisions along the way, and the real-world use cases that shaped its development.