coup docs

They took the render loop. Take it back. One dependency. No build step.
Read the manifesto →

Quick Start

Create an index.html and an app.js. That's your project.

<!-- index.html -->
<script type="importmap">
{
  "imports": {
    "lit-html": "https://esm.sh/lit-html@3",
    "lit-html/": "https://esm.sh/lit-html@3/",
    "coup": "./index.js"
  }
}
</script>

<my-counter></my-counter>
<script type="module" src="app.js"></script>

Exports

Everything you need comes from one import. No separate packages, no peer dependencies, no version matrix.

import { CoupElement, Store, html, svg, nothing } from 'coup'
import { repeat } from 'lit-html/directives/repeat.js'
ExportSourceWhat
CoupElementcoupBase class for components
StorecoupObservable state container
html, svglit-htmlTagged template functions for DOM
nothinglit-htmlRender-nothing sentinel
repeatlit-htmlKeyed list rendering

Defining Components

A coup component is just a class that extends CoupElement. Give it a tag name with a hyphen (that's a web component rule, not ours), write a template() method, and call define() to register it with the browser.

After that, you can use it in HTML like any other element — because it is an element.

class UserCard extends CoupElement {
  static tag = 'user-card'
  template() { return html`<p>Hello!</p>` }
}
UserCard.define()

define() registers your class with the browser as a custom element. Once defined, you can use <user-card> anywhere in your HTML — the browser knows what it is.

template()

This is where your component's HTML lives. Write it using lit-html's html tagged template — it looks like regular HTML, but you can drop in JavaScript expressions with ${...}. Whenever coup renders your component, it calls template() and efficiently updates only the parts of the DOM that actually changed.

template() {
  return html`
    <h2>${this.title}</h2>
    <p>${this.body}</p>
  `
}

The ${...} expressions can be strings, numbers, other html templates, arrays — whatever your UI needs. lit-html remembers where each expression is in the DOM and only touches those spots on re-render. No virtual DOM, no full-tree diffing.

render()

Props auto-render when changed. For everything else, you call this.render(). That's the coup — you control every render. It's synchronous — the DOM updates immediately, so you always know exactly when the user sees what.

this.render() is synchronous. After an await, you know the DOM is updated on the next line. No requestAnimationFrame guessing. Props and store changes batch via microtask, but explicit this.render() updates the DOM right now.

State

Component state is a plain object on this.state. Mutate it however you like, then call this.render() to update the DOM. No magic, no proxies, no auto-rendering — you decide exactly when the UI updates.

The pattern

Every state workflow follows the same two steps: mutate this.state, then call this.render(). Multiple mutations before a single this.render() are fine — you control the batch.

// Multiple changes, one render
this.state.count = 5
this.state.label = 'done'
this.state.items = [...this.state.items, newItem]
this.render()

Async state

For async workflows, call this.render() at each point where the user should see a change — once to show loading, once when data arrives:

async load() {
  this.state.loading = true
  this.render()  // show loading state

  const res = await fetch('/api/data')
  this.state.data = await res.json()
  this.state.loading = false
  this.render()  // show data
}

Props

Props are how parent components pass data to children. Declare them with static props, and coup creates getters and setters for you. When a prop changes, the child re-renders automatically.

class UserCard extends CoupElement {
  static tag = 'user-card'
  static props = { name: String, role: String }

  template() {
    return html`<div><strong>${this.name}</strong> — ${this.role}</div>`
  }
}

// Parent passes props via lit-html property binding:
html`<user-card .name=${'Ada'} .role=${'Engineer'}></user-card>`

If a parent sets three props at once (which lit-html does when rendering), coup batches them into a single re-render instead of three. And if you set a prop to the same value it already has, nothing happens — no wasted renders.

Don't need types? Array-style works too: static props = ['name', 'role']

Attributes

Props = JavaScript data from parent components (objects, arrays, any type). Attrs = HTML attribute strings from markup. Most of the time you only need props. Use attrs when you want to configure a component directly in HTML.

Declare them with static attrs, and coup watches for attribute changes and coerces the string value to the right type:

class StatusBadge extends CoupElement {
  static tag = 'status-badge'
  static attrs = { status: String }

  attributeChangedCallback(name, oldVal, newVal) {
    super.attributeChangedCallback(name, oldVal, newVal)
    this.render()
  }

  template() {
    const colors = { online: '#51cf66', offline: '#aaa', busy: '#ff6b6b' }
    const status = this._attrs.status
    return html`<span style="color: ${colors[status]}">● ${status}</span>`
  }
}

// Now you can write this directly in HTML:
// <status-badge status="online"></status-badge>
TypeCoercion
StringValue as-is. null → undefined
NumberNumber(value). null → undefined
Booleantrue if attribute present, false if removed

Bridging attrs to props (advanced)

If you declare the same name in both static attrs and static props, attribute changes automatically set the prop — which means auto-re-rendering. This is useful when you want a component that works both from HTML markup and from parent components passing data via JavaScript.

class StatusBadge extends CoupElement {
  static tag = 'status-badge'
  static attrs = { status: String }
  static props = { status: String }  // same name → attrs flow into props

  template() {
    const colors = { online: '#51cf66', offline: '#aaa', busy: '#ff6b6b' }
    return html`<span style="color: ${colors[this.status]}">● ${this.status}</span>`
  }
}

// Works from HTML:  <status-badge status="online"></status-badge>
// Works from JS:    html`<status-badge .status=${'busy'}></status-badge>`

Events

Need to listen for keyboard shortcuts, custom events from other components, or browser events? Declare them in static events and coup handles the rest — listeners are added when your component connects to the DOM and removed when it disconnects. No manual addEventListener/removeEventListener bookkeeping. No leaked listeners.

emit()

The flip side of static events. When a component needs to tell the world something happened, it calls this.emit(). This fires a CustomEvent on window, and any component with a matching static events entry hears it. No event bus library. No prop drilling callbacks through five layers. Just emit and listen.

// Child emits:
this.emit('item:selected', { id: 42 })

// Parent listens:
static events = { 'item:selected': 'onItemSelected' }

onItemSelected(e) {
  console.log(e.detail.id) // 42
}

Events flow through window, so any component anywhere in your app can listen — even if they're in completely different parts of the DOM tree. It's pub/sub built into the browser.

Store

When two or more components need to read and write the same data, put it in a Store. A Store holds your state, notifies subscribers when it changes, and that's it. No reducers, no actions, no selectors, no middleware. Create a store, read .state, call .set() to update it.

Store API

const store = new Store({ count: 0, items: [] })

// Read
store.state.count  // 0

// Update (object — shallow merge)
store.set({ count: 1 })

// Update (function — derive from current state)
store.set(s => ({ count: s.count + 1 }))

// Subscribe (returns unsubscribe function)
const unsub = store.subscribe(() => console.log('changed'))
unsub()

static subscribe

Connecting a component to a store is one line. Declare static subscribe = [myStore] and coup will automatically re-render your component whenever that store updates. Subscribe to as many stores as you need — each change triggers a re-render so your UI stays in sync.

class Dashboard extends CoupElement {
  static tag = 'app-dashboard'
  static subscribe = [userStore, settingsStore]

  template() {
    return html`<p>${userStore.state.name} — ${settingsStore.state.theme}</p>`
  }
}

When your component disconnects from the DOM, subscriptions are cleaned up automatically — no memory leaks. And this isn't limited to coup's Store. Anything with a subscribe(fn) → unsubscribe contract works: a router, a websocket wrapper, whatever you build.

storeChanged()

Sometimes a store change means you need to do work before rendering — fetch data from an API, read from IndexedDB, run a computation. Define storeChanged(store, newState) and coup hands you full control. It won't auto-render; you call this.render() when your async work is done. This is the async-friendly pattern that makes coup great for real apps.

async storeChanged(store, newState) {
  // Do async work — fetch, IndexedDB, whatever
  const data = await fetch(`/api/${newState.id}`)
  this.state.detail = await data.json()
  this.render()  // render when ready, not before
}

The rule is simple:

Lifecycle

A component's life is a straight line, not a flowchart. Six hooks cover everything from birth to death. Compare that to React's useEffect dependency arrays or Angular's change detection strategies — coup gives you a hook for each moment that matters and nothing else.

constructor
  ↓
connectedCallback  ← browser inserts element into DOM
  ↓
connected()        ← set up listeners, fetch data
  ↓
propsChanged()    ← initial props from parent (batched)
  ↓
first render → template() → lit-html → DOM
  ↓
firstUpdated()    ← one-time DOM setup (first render only)
  ↓
updated()          ← DOM is ready, measure / scroll / init widgets
  ↓
state changes → mutate this.state + this.render()
prop changes → propsChanged() → auto re-render
store changes → storeChanged() or auto re-render
  ↓
disconnectedCallback ← browser removes element
  ↓
disconnected()      ← clean up timers, listeners

connected()

Your component just appeared in the DOM. This is where you do setup work — fetch data from an API, start an interval timer, initialize a third-party library. It's the equivalent of React's componentDidMount or the "run once on mount" useEffect, except it's just a method with a clear name.

firstUpdated()

Fires once after the first render — the DOM is populated and you can safely query elements, bind scroll listeners, initialize third-party widgets, or focus inputs. Unlike connected(), the DOM already has your template's content. Unlike updated(), it only fires once.

Does not re-fire on reconnection. If the element is removed and re-inserted into the DOM, connected() fires again but firstUpdated() does not. This makes it safe for one-time setup that shouldn't repeat.

firstUpdated() {
  // DOM is populated — safe to query elements
  this.$('input').focus()
  this._chart = new Chart(this.$('canvas'), chartConfig)
}

Before and after

Before firstUpdated(), one-time DOM setup required a guard flag in updated():

// ❌ Before: guard flag in updated()
updated() {
  if (!this._initialized) {
    this._initialized = true
    this.$('.scroll-container').addEventListener('scroll', this._onScroll)
  }
}

// ✅ After: clean firstUpdated()
firstUpdated() {
  this.$('.scroll-container').addEventListener('scroll', this._onScroll)
}

disconnected()

Your component just left the DOM. Clean up anything you started in connected() — clear timers, close websockets, release resources. This always fires, even if the component was removed by a parent re-render. No leaked intervals, no orphaned listeners.

connected() {
  this._interval = setInterval(() => {
    this.state.elapsed++
    this.render()
  }, 1000)
}

disconnected() {
  clearInterval(this._interval)  // no leaks
}

propsChanged()

When a parent component changes your props, propsChanged fires with every change batched into one call. You get an object showing what changed — old values, new values, all at once. This is where you trigger side effects like fetching new data when an ID prop changes.

propsChanged(changes) {
  // changes = { propName: { old: prevValue, new: newValue }, ... }
  if ('projectId' in changes) {
    this.loadProject(changes.projectId.new)
  }
}

You don't need to call this.render() here — prop changes already trigger an automatic re-render. Think of propsChanged as a notification: "hey, your inputs changed, do anything you need to do." The render happens for you.

updated()

The DOM just updated. This is the moment to do things that need the real DOM to be ready — measure an element's height, scroll a chat window to the bottom, tell CodeMirror to refresh, initialize a chart. If you've used React, this is like the "after paint" part of useEffect — but it only fires after a successful render. If your template() threw an error, updated() is skipped.

updated() {
  // DOM is up to date — safe to measure or init widgets
  const el = this.$('.scroll-container')
  if (el) el.scrollTop = el.scrollHeight
}

Router

A tiny hash-based router for single-page apps. It uses #/path URLs, which means it works on any static file server — GitHub Pages, S3, Nginx, python -m http.server — with zero configuration. No server-side rewrites, no catch-all routes. Just push your HTML and go.

Router API

import { Router } from './router.js'

const router = new Router(['/home', '/user/:id', '/files/*'])

router.go('/user/42')       // navigate (adds history entry)
router.replace('/home')    // navigate (no history entry)
router.pattern               // '/user/:id'
router.params                // { id: '42' }
router.path                  // '/user/42'
router.isActive('/user/:id') // true

const unsub = router.subscribe(() => {
  console.log(router.pattern, router.params)
})
router.destroy()  // clean up listeners

QueryClient

An optional fetch cache for when you need caching, deduplication, retry, and prefetching. It's a dumb cache, not a reactivity system — you call fetch(), get data, call this.render(). No background timers, no invisible refetching, no framework coupling.

Creating a client

import { QueryClient } from 'coup/query.js'

const qc = new QueryClient({
  staleTime: 60_000,   // data considered fresh for 1 min (default)
  gcTime: 300_000,     // unused entries garbage-collected after 5 min (default)
  retry: 3,            // retry failed fetches (default)
})

Fetching with cache

const users = await qc.fetch(['users', page], {
  fn: ({ signal }) => fetch(`/api/users?page=${page}`, { signal })
    .then(r => r.json()),
})

If the data is fresh, it returns from cache instantly — no network request. If stale or missing, it fetches, caches, and returns. The signal is an AbortSignal for automatic cancellation.

Reading cache synchronously

const cached = qc.get(['users', page])  // data or undefined

Use this for instant cache-first rendering — show cached data immediately, fetch only on miss:

this.state.detail = qc.get(['detail', id]) || null
if (this.state.detail) { this.render(); return }  // cache hit — one render, no async

const data = await qc.fetch(['detail', id], { fn })  // cache miss — fetch it

Prefetching

// Fire-and-forget — preloads the next page while user views current one
qc.prefetch(['users', page + 1], { fn })

Invalidation & cancellation

qc.invalidate(['users'])    // mark all 'users' entries as stale (prefix match)
qc.cancel(['users'])       // abort in-flight 'users' request
qc.clear()                  // nuke everything

Manual cache writes

// Optimistic update — set cache to expected value, render, let fetch confirm
qc.set(['users', page], optimisticData)

API summary

MethodWhat it does
fetch(key, { fn })Return cached data if fresh, otherwise fetch and cache
get(key)Read cache synchronously (undefined on miss)
set(key, data)Write to cache manually (optimistic updates)
prefetch(key, { fn })Like fetch but swallows errors (fire-and-forget)
invalidate(prefix)Mark matching entries as stale (prefix match)
cancel(prefix)Abort in-flight requests (prefix match)
clear()Clear all cache and cancel all in-flight requests

Debugging

QueryClient stores everything in plain Maps. Since qc is typically module-scoped, expose it on window in debug mode to inspect in DevTools:

if (CoupElement.debug) window.__qc = qc

Then in the console:

__qc._cache      // all cached entries (key → { data, staleAt, gcAt })
__qc._inflight   // in-flight promises
__qc._aborts     // active AbortControllers

See examples/13-movies/ for a working demo with search, pagination, prefetching, and cache hits.

Debug Mode

Flip one switch and coup watches your back.

CoupElement.debug = true     // warnings + render monitor + cheap render log
CoupElement.debug = 'trace'  // ...plus a stack trace per render
CoupElement.debug = false    // off (production default)

When enabled (true or 'trace'):

Zero cost when off — all checks are gated behind the flag, and the Store.set fast path is byte-for-byte identical to production.

Render monitor

coup re-renders a whole component at a time, so the same store write is nearly free in a small leaf and expensive in the app shell. A value that ticks on a timer, a drag, or a stream can quietly re-render an ancestor dozens of times a second. That asymmetry is the framework's main performance trap, so debug mode measures it.

Every render is attributed to the store key that scheduled it: Store.set records which keys actually changed value, and any render triggered during that update is credited to them. When one tag renders more than 25 times in a second, coup warns once (with a 5s cooldown), naming the writer rather than the victim. The keys and the worst-render time come from that one-second window, so a key that drove renders earlier in the component's life is never blamed for a storm it did not cause:

[coup] render storm: <app-root> rendered 26x in the last 1000ms
(0.6ms worst render). Store keys written nearby: sidebarWidth(198).
If one value changes continuously (a timer, a drag, a stream), write
it to the DOM directly and commit to the store once when it settles.

Console helpers (debug mode only):

Render stack traces

Stack traces are their own level — console.trace() formats a full stack on every render and, during a storm, buries the signal under identical stacks. debug = true logs the cheap one-line entry instead. When you need to know where a render was scheduled from, opt in:

CoupElement.debug = 'trace'  // a strict superset of true

Traces are coalesced to the first render of a burst — a storm logs one stack, not one per render — and trace again once the tag idles for a second.

Live production debugging

To enable debug mode from the browser console in production, expose CoupElement on window somewhere in your app:

if (typeof window !== 'undefined') window.CoupElement = CoupElement

Then open the console and type:

CoupElement.debug = true

Renders will start logging immediately. If a component errors, the overlay appears. No rebuild needed.

For Web Component Developers

If you already know custom elements, coup will feel familiar — but some things are intentionally different. Here's what you need to unlearn, and what you get instead.

What coup replaces

Raw custom elementCoup equivalent
connectedCallback() + super connected() — no super call needed, coup handles the plumbing
disconnectedCallback() + super disconnected() — same deal, just your cleanup code
attributeChangedCallback() +
static get observedAttributes()
static attrs = { name: Type } — coup generates both, coerces types (String/Number/Boolean), and auto-re-renders
this.attachShadow() Not used. Coup renders to light DOM. Your CSS just works — regular selectors, no ::part(), no ::slotted()
this.innerHTML = ... or render library template() returns html`...`, coup calls lit-html's render
this.dispatchEvent(new CustomEvent(...)) this.emit(name, detail) — fires on window
Manual addEventListener/removeEventListener static events = { 'name': 'handler' } — auto-bind on connect, auto-cleanup on disconnect

Things that might trip you up

No Shadow DOM. Coup renders into the element itself (light DOM). If you're used to style encapsulation via shadow root, that's not here — and it's intentional. Shadow DOM adds complexity (slotting, ::part(), closed vs open mode) that most apps don't need. Style your coup components like any other HTML element.

Props ≠ Attributes. In raw web components, attributes and properties are the same headache. In coup, they're separate systems with a clean bridge. static props handles JavaScript property passing — a parent sets .name=${'Ada'} via lit-html. static attrs handles HTML attribute strings like <my-el name="Ada">. If you declare the same name in both, attribute changes automatically flow into the prop system.

Never call super.connectedCallback(). Coup's connected() and disconnected() are clean hooks — coup calls them at the right time after its own setup. If you override connectedCallback directly (don't), you'd need the super call, but that's fighting the framework.

this.render() is synchronous. It calls lit-html's render() immediately. Prop changes and store changes batch via microtask, but when you explicitly call this.render(), the DOM updates right now. After an await, you know the DOM is updated on the next line. No requestAnimationFrame guessing.

template() returns a tagged template, not a string. Not innerHTML, not DOM nodes. lit-html's html tagged template looks like a string but it's a structured template that enables efficient diffing. If you're used to building DOM manually, let go — html`...` handles it.

No adoptedCallback. Moving elements between documents is edge-case territory coup doesn't cover. If you need it, you probably need a different tool.

define() is idempotent. Call MyEl.define() multiple times and it only registers once. No need to guard with customElements.get() — coup already does.

Helpers

$(selector)

A convenience shortcut for this.querySelector(selector). Grab a child element without the verbosity.

const input = this.$('input.name')

$$(selector)

Like $() but returns all matches as a real Array (not a NodeList), so you can .map(), .filter(), and .forEach() directly.

this.$$('.item').forEach(el => el.classList.add('active'))

emit(name, detail)

Fires a CustomEvent on window with the given name and detail payload. Any component with a matching static events entry will hear it. See Events above for the full pattern.

repeat(items, keyFn, template)

Re-exported from lit-html. When you're rendering a list and items can be added, removed, or reordered, use repeat() with a key function. This tells lit-html how to track each item, so it moves DOM nodes instead of recreating them. Essential for drag-and-drop, animations, and any list where order matters.

import { repeat } from 'lit-html/directives/repeat.js'

template() {
  return html`
    <ul>
      ${repeat(this.items, i => i.id, i => html`
        <li>${i.name}</li>
      `)}
    </ul>
  `
}

lit-html Cheatsheet

Coup uses lit-html for templates. If you've used React's JSX, the syntax is different but the idea is the same — you describe your UI and the library figures out the minimal DOM changes. Here's the syntax you need to know:

Text and expressions

html`<p>Hello ${name}</p>`                  // text interpolation
html`<p>${isAdmin ? 'Admin' : 'User'}</p>`  // ternary
html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`  // arrays

Attributes vs properties

This is the biggest source of confusion. There are four prefixes:

SyntaxWhat it doesWhen to use
attr=${val} Sets an HTML attribute (always a string) Standard HTML attributes: class, id, href, style, src
.prop=${val} Sets a JavaScript property (any type) Passing objects, arrays, or data to coup components: .items=${myArray}, .user=${obj}
?attr=${bool} Adds attribute if true, removes if false Boolean attributes: ?disabled=${loading}, ?checked=${val}, ?hidden=${!show}
@event=${fn} Adds an event listener Any DOM event: @click, @input, @submit, @keydown

The most common mistake: using attr=${obj} when you mean .prop=${obj}. Attributes are strings — if you pass an object as an attribute, you'll get [object Object] in the DOM. Use the dot prefix for anything that isn't a string.

Passing data to child components

// ✅ Use .dot for objects, arrays, anything non-string
html`<user-card .user=${this.user}></user-card>`

// ✅ Plain attributes work for strings
html`<status-badge status="online"></status-badge>`

// ❌ This passes "[object Object]" as a string
html`<user-card user=${this.user}></user-card>`

Event handlers

// Arrow wrapper — always works
html`<button @click=${() => this.save()}>Save</button>`

// Prevent default (forms, links)
html`<form @submit=${(e) => { e.preventDefault(); this.send() }}>`

Binding tip: passing a regular method directly (@click=${this.save}) won't work — this gets lost. Either wrap it in an arrow, or define the handler as a class field arrow:

// Class field arrow — 'this' is auto-bound, safe to pass directly
save = () => {
  // 'this' is always the component instance
}
html`<button @click=${this.save}>Save</button>`

Rendering nothing

import { nothing } from 'coup'

// nothing = zero DOM nodes. Use instead of '' or null to avoid empty text nodes.
html`${this.error ? html`<p class="err">${this.error}</p>` : nothing}`

Selects with dynamic options: bind ?selected

Template parts commit in document order, so a .value= binding on a <select> commits before its dynamically-rendered <option>s are in the DOM. The browser silently ignores it and auto-selects the first option — even when the store holds a later one. Bind ?selected on each option instead, so the selection travels with the option and is correct regardless of render order:

// ❌ .value commits before the options exist — first option wins
html`<select .value=${current}>${voices.map(v => html`<option value=${v.id}>${v.name}</option>`)}</select>`

// ✅ ?selected rides along with each option
html`<select>${voices.map(v => html`<option value=${v.id} ?selected=${v.id === current}>${v.name}</option>`)}</select>`

Debug mode warns when it detects this pattern.

Styling

Coup renders into the light DOM — no Shadow DOM, no CSS encapsulation boundary. This is a feature, not a limitation. Your components are just HTML elements, and you style them with regular CSS.

Component-scoped styles via tag name

Since every component has a unique tag name, use it as your scope:

/* Styles only apply inside <task-list> elements */
task-list {
  display: block;
  padding: 1rem;
}
task-list .item {
  border-bottom: 1px solid #eee;
  padding: 0.5rem 0;
}
task-list .item.done {
  opacity: 0.5;
  text-decoration: line-through;
}

This gives you practical scoping without the complexity of shadow DOM. Can styles leak? Yes — but that's also why they compose. Your app's base styles (fonts, colors, resets) apply everywhere, including inside components. That's usually what you want.

Inline styles for dynamic values

html`<div style="background:${this.color}; opacity:${this.active ? 1 : 0.5}">`

CSS files per component

For larger apps, put each component's styles in a CSS file that mirrors the JS file:

components/
  task-list.js
  task-list.css    ← imported via <link> in index.html
  task-item.js
  task-item.css

Project Structure

Coup doesn't have a CLI or prescribed layout. Here's what works well as your app grows:

Small app (1–5 components)

index.html          ← importmap, styles, <my-app>
app.js              ← all components in one file

This is how the coup examples are structured. One HTML file, one JS file. When a file hits ~400 lines, split it up.

Medium app (5–20 components)

index.html
js/
  app.js            ← root component, imports others
  store.js          ← shared stores
  components/
    header.js
    sidebar.js
    task-list.js
    task-item.js
css/
  base.css          ← resets, variables, typography
  components.css    ← all component styles

Large app (20+ components)

index.html
coup/               ← coup source (vendored or symlinked)
  index.js
  router.js
js/
  app.js
  store.js          ← global app store
  db.js             ← IndexedDB wrapper
  components/
    editor.js       ← CodeMirror integration
    preview.js      ← live preview with iframe
    file-browser.js
    settings.js
vendor/
  codemirror.js     ← vendored third-party libs

Rules of thumb

Common Mistakes

Real bugs from production apps. One of these took us from 169 renders per click down to 1.

Forgetting this.render()

The most common mistake. You update state but nothing changes on screen. Every state mutation needs a corresponding this.render() call.

// ❌ Nothing happens — forgot render()
this.state.count++

// ✅ Update state, then render
this.state.count++
this.render()

Turn on debug mode (CoupElement.debug = true) during development — it warns when you mutate state without rendering.

Overriding connectedCallback instead of connected()

Coup sets up props, stores, and events in connectedCallback. If you override it, you skip that setup.

// ❌ Breaks prop/store setup
connectedCallback() {
  this.loadData()
}

// ✅ Use the clean hook
connected() {
  this.loadData()
}

Passing objects without the dot prefix

// ❌ Attributes are strings — this sets item="[object Object]"
html`<task-item item=${obj}></task-item>`

// ✅ Dot prefix passes the JavaScript value
html`<task-item .item=${obj}></task-item>`

.bind() in templates causes child re-renders

Every call to .bind() creates a new function reference. If you pass it as a prop, the child sees a "changed" prop on every parent render and re-renders for nothing.

// ❌ New function every render → child re-renders every time parent does
html`<my-child .onClick=${this.handle.bind(this)}>`

// ❌ Same problem — arrow wrapper in template creates a new function too
html`<my-child .onClick=${() => this.handle()}>`

// ✅ Class field arrow — bound once, same reference every render
handle = () => { /* ... */ }
html`<my-child .onClick=${this.handle}>`

This is JavaScript, not a coup bug — === fails for new function objects. In a real app we saw 169 renders per click traced back to this pattern. Use class field arrows for any function passed as a prop.

Note: this only matters for props (dot-prefixed like .onClick). Event handlers (@click) are fine with inline arrows — lit-html handles those differently and they don't trigger child re-renders.

Async storeChanged without this.render()

If you define storeChanged, coup doesn't auto-render — you own it. If your storeChanged does async work and never calls this.render(), the component goes stale.

// ❌ Component never updates
async storeChanged(store, state) {
  await this.loadData()
  // forgot this.render()
}

// ✅ Render when the async work is done
async storeChanged(store, state) {
  await this.loadData()
  this.render()
}

Leaking event listeners

If you manually add event listeners in connected(), always remove them in disconnected(). Better yet, use static events — coup handles cleanup automatically.

// ❌ Leaks — anonymous function can't be removed
connected() {
  window.addEventListener('resize', () => this.onResize())
}

// ✅ Option A: save the reference
connected() {
  this._onResize = () => this.onResize()
  window.addEventListener('resize', this._onResize)
}
disconnected() {
  window.removeEventListener('resize', this._onResize)
}

// ✅ Option B: let coup handle it
static events = { 'resize': 'onResize' }

Object mutation without rendering

Mutating objects in this.state is fine — but you still need to call this.render() afterward. The mutation itself doesn't trigger anything.

// ❌ Mutated but forgot render()
this.state.items.push(newItem)

// ✅ Mutate, then render
this.state.items.push(newItem)
this.render()

// ✅ Same for nested objects
this.state.user.name = 'Ada'
this.render()

Rendering on every external event

SDKs, WebSockets, and browser APIs can fire events much faster than you'd expect. If you call this.render() on every event without checking whether anything actually changed, you get silent over-rendering.

// ❌ Spotify SDK fires 5+ events per track change — renders every time
onPlayerState(playState) {
  this.state.track = playState.track
  this.state.paused = playState.paused
  this.render()
}

// ✅ Guard — only render when something actually changed
onPlayerState(playState) {
  const trackChanged = playState.track?.id !== this.state.track?.id
  const pauseChanged = playState.paused !== this.state.paused
  if (trackChanged) this.state.track = playState.track
  if (pauseChanged) this.state.paused = playState.paused
  if (trackChanged || pauseChanged) this.render()
}

This guard took one of our apps from 169 renders per play click down to 1. The pattern: compare first, mutate if different, render once.

Redundant render in connected()

Coup already schedules a render when a component connects. Calling this.render() at the end of connected() doubles the initial render.

// ❌ Renders twice on mount
connected() {
  this.state.items = ['a', 'b', 'c']
  this.render()  // coup already scheduled one
}

// ✅ Just set state — coup's auto-render picks it up
connected() {
  this.state.items = ['a', 'b', 'c']
}

// ✅ Async is different — render after the await
async connected() {
  this.state.data = await fetch('/api/items').then(r => r.json())
  this.render()  // needed — microtask already fired
}

Direct DOM writes vs this.render()

For high-frequency updates (scrubbers, scroll positions, animations), rendering the full template every frame can feel wasteful. You can skip the render and write directly to the DOM instead:

// Direct DOM write — updates one element without re-rendering
this.state.position = newValue
this.$('input[type=range]').value = newValue

This is fast, but anything else in your template that reads this.state.position (timestamps, progress bars, labels) won't update until the next full render. You're trading consistency for speed. Often the right call is to just call this.render() — lit-html's diffing is fast enough for once-per-second updates. Save direct DOM writes for truly hot paths like mousemove or requestAnimationFrame loops.

Not cleaning up timers

// ❌ Timer fires after component is removed — error or stale render
connected() {
  setInterval(() => this.tick(), 1000)
}

// ✅ Save the ID, clear on disconnect
connected() {
  this._timer = setInterval(() => this.tick(), 1000)
}
disconnected() {
  clearInterval(this._timer)
}

Deployment

Coup apps are static files. No server-side rendering, no serverless functions, no build artifacts. Push your HTML, JS, and CSS to any static host and you're live.

GitHub Pages

git push origin main
# That's it. Settings → Pages → deploy from main branch.

Any static host

Netlify, Vercel, Cloudflare Pages, S3, Surge — anything that serves files works. No build command, no output directory configuration. The source is the build.

Local development

You need a local server (ES modules don't load from file://). Any of these work:

# Python
python3 -m http.server 3000

# Node
npx serve . -p 3000

# PHP
php -S localhost:3000

No hot module replacement, no file watchers, no dev server config. Refresh the browser. Your code is your code — the browser runs it directly.

Pattern: Extending CoupElement

Coup is a class. You can subclass it. If every component in your app needs the same capabilities — abort control, toast notifications, a shared fetch helper — create your own base class and extend that instead of CoupElement directly. The framework is a starting point, not a ceiling.

Example: automatic fetch cancellation

Most apps have components that fetch data. When the component is removed (navigated away, tab switched), in-flight requests should be cancelled. Instead of writing AbortController boilerplate in every component, bake it into a base class:

// base.js — your app's base class
import { CoupElement } from 'coup'

class AppElement extends CoupElement {
  // Always available — returns the current signal
  get abortSignal() {
    if (!this._abort) this._abort = new AbortController()
    return this._abort.signal
  }

  // Cancel in-flight work, return a fresh signal
  abort() {
    this._abort?.abort()
    this._abort = new AbortController()
    return this._abort.signal
  }

  disconnectedCallback() {
    this._abort?.abort()  // auto-cancel on removal
    super.disconnectedCallback()
  }
}

export { AppElement }

Now every component gets cancellation for free:

import { AppElement } from './base.js'

class UserProfile extends AppElement {
  static tag = 'user-profile'
  static props = { userId: String }

  state = { user: null, loading: true }

  async connected() {
    await this.loadUser()
  }

  propsChanged(changes) {
    if ('userId' in changes) this.loadUser()
  }

  async loadUser() {
    const signal = this.abort()  // cancel previous, get fresh signal
    this.state.loading = true
    this.render()
    try {
      const res = await fetch(`/api/users/${this.userId}`, { signal })
      this.state.user = await res.json()
    } catch (e) {
      if (e.name === 'AbortError') return  // cancelled — do nothing
      throw e
    }
    this.state.loading = false
    this.render()
  }

  template() { /* ... */ }
}

No cleanup in disconnected(). No manual AbortController management. Navigate away mid-fetch and the request is cancelled automatically.

Other things worth adding to a base class

Keep the base class small. If only some components need a capability, use composition instead — import a function, not a base class.

Composition over inheritance

Instead of baking capabilities into a base class that every component inherits, write small composable helpers that only the components that need them opt into:

// fetchable.js — composable fetch helper
export function fetchable() {
  let abort = new AbortController()
  return {
    get signal() { return abort.signal },
    restart() {
      abort.abort()
      abort = new AbortController()
      return abort.signal
    },
    destroy() { abort.abort() }
  }
}
// Only components that fetch use it
import { fetchable } from './fetchable.js'

class UserProfile extends CoupElement {
  static tag = 'user-profile'
  static props = { userId: String }
  state = { user: null, loading: true }

  connected() {
    this._fetch = fetchable()
    this.loadUser()
  }

  disconnected() {
    this._fetch.destroy()
  }

  propsChanged(changes) {
    if ('userId' in changes) this.loadUser()
  }

  async loadUser() {
    const signal = this._fetch.restart()
    this.state.loading = true
    this.render()
    try {
      const res = await fetch(`/api/users/${this.userId}`, { signal })
      this.state.user = await res.json()
    } catch (e) {
      if (e.name === 'AbortError') return
      throw e
    }
    this.state.loading = false
    this.render()
  }

  template() { /* ... */ }
}

The power of composition: you can mix multiple capabilities without an inheritance chain:

connected() {
  this._fetch = fetchable()
  this._resize = resizeObserver(this, () => this.render())
  this._keys = keyboard(this, { 'Cmd+S': () => this.save() })
}

disconnected() {
  this._fetch.destroy()
  this._resize.destroy()
  this._keys.destroy()
}

When to use which: if every component in your app needs it (auth, analytics), put it in a base class. If only some components need it (fetching, resize observers, keyboard shortcuts), use composition.

Pattern: Animations

Coup doesn't have an animation system — the browser already has a great one. The key insight: updated() fires after the DOM renders, so it's the perfect place to trigger enter animations. Exit animations need a bit more thought because you have to delay removal until the animation finishes.

Enter animation

Add a CSS class that triggers the animation. Since updated() fires after the DOM is ready, your new elements exist and the browser can animate them.

// CSS:
// .fade-in { animation: fadeIn 0.3s ease; }
// @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

template() {
  return html`
    <div class="fade-in">${this.content}</div>
  `
}

Exit animation

The trick: don't remove the element immediately. Mark it as "leaving," render with an exit animation class, then actually remove it when the animation finishes.

removeItem(item) {
  item.leaving = true       // mark it
  this.render()              // render with .leaving class
}

updated() {
  const el = this.$('.leaving')
  if (el) {
    el.addEventListener('animationend', () => {
      this.state.items = this.state.items.filter(i => !i.leaving)
      this.render()           // now actually remove
    }, { once: true })
  }
}

template() {
  return html`
    <ul>
      ${this.items.map(i => html`
        <li class="${i.leaving ? 'slide-out' : ''}">${i.name}</li>
      `)}
    </ul>
  `
}

FLIP technique (list reorder)

For smooth reorder animations, capture positions before the render, then animate from old to new positions after.

reorder(newItems) {
  // 1. Capture current positions
  const rects = new Map()
  this.$$('.item').forEach(el => {
    rects.set(el.dataset.id, el.getBoundingClientRect())
  })

  // 2. Update state and render
  this.state.items = newItems
  this._rects = rects
  this.render()
}

updated() {
  if (!this._rects) return
  const old = this._rects
  this._rects = null

  // 3. Animate from old position to new
  this.$$('.item').forEach(el => {
    const prev = old.get(el.dataset.id)
    if (!prev) return
    const curr = el.getBoundingClientRect()
    const dx = prev.left - curr.left
    const dy = prev.top - curr.top
    if (dx || dy) {
      el.animate([
        { transform: `translate(${dx}px, ${dy}px)` },
        { transform: 'translate(0, 0)' }
      ], { duration: 300, easing: 'ease' })
    }
  })
}

Pattern: Async Data Loading

Most apps fetch data. The pattern is always the same: start in a loading state, fetch in connected(), call this.render() when done. Handle errors explicitly — no Suspense boundaries, no error boundaries, just a try/catch and a state field.

class UserProfile extends CoupElement {
  static tag = 'user-profile'
  static props = { userId: String }

  state = { user: null, loading: true, error: null }

  async connected() {
    await this.load()
  }

  propsChanged(changes) {
    if ('userId' in changes) this.load()
  }

  async load() {
    this.state.loading = true
    this.state.error = null
    this.render()

    try {
      const res = await fetch(`/api/users/${this.userId}`)
      if (!res.ok) throw new Error(`${res.status}`)
      this.state.user = await res.json()
      this.state.loading = false
    } catch (e) {
      this.state.error = e.message
      this.state.loading = false
    }
    this.render()
  }

  template() {
    if (this.state.loading) return html`<p>Loading...</p>`
    if (this.state.error) return html`<p class="error">Failed: ${this.state.error}</p>`
    return html`<h2>${this.state.user.name}</h2>`
  }
}

Notice: two this.render() calls — one to show the loading state immediately, one when the data arrives. Both intentional. You see exactly when the user sees what.

Pattern: Forms

Coup doesn't have two-way binding or controlled inputs. You don't need them — the browser already has FormData, and it works beautifully. Grab the form on submit, turn it into an object, send it. No state syncing, no onChange handlers for every field.

FormData → JSON request

This is the pattern you'll use most. The form holds its own state (that's what forms do), and you read it all at once on submit. Hidden fields work too — great for IDs, tokens, or metadata the user doesn't need to see.

class ContactForm extends CoupElement {
  static tag = 'contact-form'

  state = { sending: false, sent: false, error: null }

  async submit(e) {
    e.preventDefault()

    // FormData reads every named field — inputs, selects, textareas, hidden
    const data = Object.fromEntries(new FormData(e.target))
    // data = { formId: "contact-v2", name: "Ada", email: "ada@...", ... }

    this.state.sending = true
    this.render()

    try {
      const res = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      })
      if (!res.ok) throw new Error(`${res.status}`)
      this.state.sent = true
    } catch (err) {
      this.state.error = err.message
    }
    this.state.sending = false
    this.render()
  }

  template() {
    if (this.state.sent) return html`<p>✅ Sent!</p>`
    return html`
      <form @submit=${(e) => this.submit(e)}>
        <input type="hidden" name="formId" value="contact-v2">

        <label>Name <input name="name" required></label>
        <label>Email <input name="email" type="email" required></label>
        <label>Subject
          <select name="subject">
            <option>General</option>
            <option>Support</option>
            <option>Feedback</option>
          </select>
        </label>
        <label>Message <textarea name="message" rows="3"></textarea></label>

        <button type="submit" ?disabled=${this.state.sending}>
          ${this.state.sending ? 'Sending...' : 'Send'}
        </button>
        ${this.state.error ? html`<p style="color:red">${this.state.error}</p>` : ''}
      </form>
    `
  }
}

Key points:

Live validation

For instant feedback as the user types, listen to @input and update state. Only use this when the user expects live results — search, filtering, character counts.

Pattern: Third-Party DOM

Some libraries — CodeMirror, Chart.js, Mapbox, YouTube embeds — need to own a chunk of the DOM. Coup plays nicely with them because template() just renders a container, and the library fills it in. The key: initialize in updated() (DOM is ready) and destroy in disconnected().

class CodeBlock extends CoupElement {
  static tag = 'code-block'
  static props = { content: String, lang: String }

  updated() {
    // Only init once — the container persists across renders
    if (!this._editor && this.content != null) {
      this._editor = new CodeMirrorEditor({
        parent: this.$('.editor-mount'),
        doc: this.content,
      })
    }
  }

  propsChanged(changes) {
    // Content changed — update the editor, not the DOM
    if ('content' in changes && this._editor) {
      this._editor.dispatch({
        changes: { from: 0, to: this._editor.state.doc.length, insert: this.content }
      })
    }
  }

  disconnected() {
    this._editor?.destroy()
  }

  template() {
    // Just a container — CodeMirror owns everything inside it
    return html`<div class="editor-mount"></div>`
  }
}

The same pattern works for any library that manages its own DOM: render an empty container, initialize in updated(), clean up in disconnected(). Coup never touches the contents — lit-html only diffs what template() returns.

Pattern: Debouncing

Search-as-you-type, auto-save, resize handling — anytime you want to wait for the user to stop doing something before reacting. No library needed, just a timer.

class SearchBox extends CoupElement {
  static tag = 'search-box'
  state = { query: '', results: [] }
  _timer = null

  onInput(e) {
    this.state.query = e.target.value
    clearTimeout(this._timer)
    this._timer = setTimeout(() => this.search(), 300)
  }

  async search() {
    const res = await fetch(`/api/search?q=${this.state.query}`)
    this.state.results = await res.json()
    this.render()
  }

  disconnected() {
    clearTimeout(this._timer)  // always clean up
  }

  template() {
    return html`
      <input @input=${(e) => this.onInput(e)} .value=${this.state.query}>
      <ul>${this.state.results.map(r => html`<li>${r.title}</li>`)}</ul>
    `
  }
}

Notice the clearTimeout in disconnected(). If the component is removed while a search is pending, the timer is cleaned up. This is the kind of thing useEffect cleanup handles in React — but here it's just a method you write.

Pattern: Component Communication

There are three ways components talk to each other. Use the simplest one that works.

Parent → Child: props

The parent renders the child with data. When the data changes, the child re-renders automatically.

// Parent template:
html`<user-card .name=${this.user.name} .role=${this.user.role}></user-card>`

Child → Parent: emit

The child fires an event. The parent (or any ancestor) listens via static events.

// Child:
this.emit('item:deleted', { id: this.itemId })

// Parent:
static events = { 'item:deleted': 'onItemDeleted' }
onItemDeleted(e) {
  this.items = this.items.filter(i => i.id !== e.detail.id)
}

Any → Any: store

When two components that aren't parent-child need to share state, use a Store. Both subscribe, both see changes.

// store.js
export const cartStore = new Store({ items: [], total: 0 })

// Any component:
static subscribe = [cartStore]
template() {
  return html`<span>${cartStore.state.items.length} items</span>`
}

Rule of thumb: start with props. If the child needs to talk back, use emit. If unrelated components need the same data, use a store. Don't reach for a store until props and events feel awkward.

Pattern: Keyed Lists

When rendering a list with .map(), lit-html updates items by position. That's fine for static lists, but if items can be reordered, added in the middle, or removed, the DOM nodes get recycled in confusing ways — inputs lose focus, animations break, component state gets mixed up.

Use repeat() to tell lit-html how to track each item by identity:

import { repeat } from 'lit-html/directives/repeat.js'

// ❌ .map() — items tracked by position
html`${this.items.map(i => html`<todo-item .item=${i}></todo-item>`)}`

// ✅ repeat() — items tracked by identity
html`${repeat(this.items, i => i.id, i => html`
  <todo-item .item=${i}></todo-item>
`)}`

When to use which:

Pattern: Conditional Rendering

Show, hide, and swap content based on state. No special directives — just JavaScript expressions inside your template.

Show/hide with ternary

template() {
  return html`
    ${this.loggedIn
      ? html`<p>Welcome, ${this.user.name}</p>`
      : html`<p><a href="#/login">Log in</a></p>`}
  `
}

Render nothing

Use nothing (from lit-html) when a condition should produce zero DOM nodes — not even a comment node.

import { nothing } from 'coup'

template() {
  return html`
    <h1>Dashboard</h1>
    ${this.isAdmin ? html`<admin-panel></admin-panel>` : nothing}
  `
}

Multi-page switching

For page-level routing or tab switching, a simple object lookup is cleaner than nested ternaries:

template() {
  const pages = {
    home:     html`<home-page></home-page>`,
    settings: html`<settings-page></settings-page>`,
    profile:  html`<profile-page .user=${this.user}></profile-page>`,
  }
  return html`
    <nav>...</nav>
    ${pages[this.page] || html`<not-found></not-found>`}
  `
}

When you switch pages, the old component disconnects (firing disconnected()) and the new one connects (firing connected()). Cleanup and setup happen automatically.


Home · Source