Open-source project
matthewp/haunted avatar
matthewp/haunted

matthewp/haunted: React Hooks for Web Components

React's Hooks API implemented for web components đź‘»

2,719 stars90 forksTypeScriptBSD-2-Clause

At a glance

What is it?
Haunted implements React's Hooks API for standard web components and lit-html or hyperHTML, letting you write function components and register them with customElements.define. The library is small, currently pushed, and tightly coupled to lit-html 3.
Who is it for?
Adopt haunted when you are already rendering with lit-html or hyperHTML and want hook-based state, effects and context with a web component wrapper you can register through customElements.define. Do not adopt it if you need a renderer it does not support, or if you want a framework that owns routing, data fetching and form handling: haunted is a hooks runtime, not an application framework.
Can I use it commercially?
Yes. BSD-2-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 12 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 24, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem haunted solves: hooks without giving up web components

React's Hooks API is a state and lifecycle model, not a rendering engine. That distinction is what makes haunted possible. If you want useState, useEffect, useMemo, useReducer, useRef, useCallback, useContext, useLayoutEffect or useController inside a custom element, haunted supplies the hook implementations and a component() wrapper that turns a plain render function into something customElements.define accepts.

The intended audience is narrow and specific: developers who have already chosen lit-html or hyperHTML as their template layer and do not want to adopt a component framework on top of it. The README's counter example is the whole pitch in about fifteen lines. A function reads useState, returns an html tagged template, and gets registered as my-counter. There is no class, no decorator, no build step implied by the example.

That is a real gap haunted fills. Web components standardise the element boundary but say nothing about how state flows inside one. Haunted answers that question with an API most React developers already know, which shortens the learning curve to nearly nothing for that group.

How component(), virtual() and the hook store fit together

The README exposes three callables. component(renderer, options) and component(renderer, baseElement, options) produce an Element you can pass to customElements.define. virtual(renderer) returns a Directive, which is the lit-html extension point for rendering a function component inside another template rather than as a standalone element. The Renderer type is declared as (element: Element) => TemplateResult, so the render function receives the host element and returns a lit-html template result.

The Options interface carries three keys: baseElement, observedAttributes and useShadowDOM. That tells you the wrapper is doing attribute plumbing for you. observedAttributes is how you tell the wrapper which attributes should trigger a re-render, which is the standard custom elements mechanism, surfaced as a config option instead of a static observedAttributes getter on a class. useShadowDOM decides whether the render target sits behind a shadow root.

The package.json declares lit and lit-html both at ^3.0.0 as runtime dependencies, and the entry points are lib/haunted.js for main, module and the typings at lib/haunted.d.ts. The build script is tsc, so the published lib/ directory is compiled TypeScript, and the files array ships only lib. There is also a custom-elements.json produced by @custom-elements-manifest/analyzer through the analyze script, which is the machine-readable manifest tooling like editors use for autocomplete. Note the coupling: haunted depends on lit-html 3, so an application pinned to lit-html 2 has a version conflict to resolve before anything else.

Installing haunted and writing a first component

The README's quickest path needs no install at all. It imports lit and haunted straight from unpkg as ES modules inside a script tag with type="module", which is useful for a scratch page but not for a bundler-based app.

html
<script type="module">
  import { html } from 'https://unpkg.com/lit?module';
  import { component, useState } from 'https://unpkg.com/haunted/haunted.js';

  function Counter() {
    const [count, setCount] = useState(0);

    return html`
      <div id="count">${count}</div>
      <button type="button" @click=${() => setCount(count + 1)}>
        Increment
      </button>
    `;
  }

  customElements.define('my-counter', component(Counter));
</script>

For a project with a package manager, the package name is haunted and the module entry is lib/haunted.js. Installing it brings lit and lit-html 3 along as dependencies.

bash
npm install haunted

Then the same component, imported from the package instead of a CDN. The import specifier in the README's CDN example is 'https://unpkg.com/haunted/haunted.js', which corresponds to the package root export; the package.json main and module fields both point at lib/haunted.js.

js
import { html } from 'lit';
import { component, useState } from 'haunted';

function Counter() {
  const [count, setCount] = useState(0);
  return html`<button @click=${() => setCount(count + 1)}>${count}</button>`;
}

customElements.define('my-counter', component(Counter));

What you should see after loading the page is a my-counter element whose button increments the displayed number on click. If the element renders nothing, the first thing to check is whether your bundler resolved lit-html to a second copy, because the hooks store and the template layer must agree on the same lit-html instance. The repository ships an examples/ directory with counter.html, memo.html, props.html, reducer.html, theme.html, title.html, use-controller.html and subfolders for lit/, skate/ and controllers/, which are the working references to compare against when your own file misbehaves.

Where haunted is the wrong tool

Haunted is a hooks runtime, not a framework, and the README does not claim otherwise. There is no router, no data-fetching layer, no form library and no server-side rendering story in the README. If your application needs those, you are assembling them yourself, and the cost of that assembly is not visible from the README.

The harder constraint is the renderer. The description names lit-html and hyperHTML specifically, and the Renderer type returns TemplateResult. That is a lit-html type. A team that has standardised on a different templating approach, or that wants to render with plain DOM calls and no template library, is outside the shape this API was designed around.

The React compatibility claim also deserves a careful reading. The README says haunted "supports the same API as React Hooks" and that the hope is you can reuse hooks from npm by aliasing package names in your bundler config. That is an aspiration expressed in the README, not a guarantee, and aliasing React internals is exactly the kind of thing that breaks on a dependency's minor release. Treat third-party React hook reuse as something to verify per package rather than as a baseline capability.

Finally, the hooks listed are the ones the README enumerates. If a React hook you rely on is not in that list, the README does not document an equivalent.

Haunted compared with Lit's own reactive elements

The closest alternative in this space is Lit itself. Lit's LitElement gives you a class with reactive properties, and its @lit/reactive-element package provides ReactiveController, which is the mechanism for sharing stateful logic between elements. The difference in approach is structural: LitElement keeps state on the class instance and declares it through a static properties field, while haunted keeps state in a hook store attached to the component instance and reads it through function calls in render order.

That difference matters in two places. First, hook rules apply. Calling useState conditionally inside a haunted render function is the same mistake as in React, and the README does not document a lint rule or a development-mode warning for it. Second, composition. A Lit controller is an object you instantiate and hand to the element; a haunted hook is a function you call. Teams that prefer explicit object composition over call-order discipline will find Lit's model easier to reason about, and teams coming from React will find haunted's model familiar enough to skip documentation.

Haunted ships useController as one of its hooks, which is worth noting: it is the bridge between the two models, letting a haunted component consume a controller-shaped piece of logic. If you are already deep in Lit's controller ecosystem, that hook is the part of haunted most likely to be useful to you, and the examples/controllers/ directory is the reference for it.

Maintenance, release cadence and the BSD-2-Clause licence

The repository is not archived and the last push was on 2026-09-18, which is recent enough that the codebase is being touched. The release history shows v6.0.0 on 2025-02-18, a v6.0.0-next.2 prerelease the same day, and v6.1.0 on 2025-02-21. That is a major version followed quickly by a minor, then a gap in tagged releases afterward. The presence of a .changeset/ directory and @changesets/cli in devDependencies indicates releases are managed through changesets, so version bumps are expected to carry changelog entries in CHANGELOG.md rather than appearing silently.

Upgrade cost is concentrated in the major versions. Haunted 6 depends on lit-html 3, so moving from a haunted 5-era project to 6 means moving lit-html too, and any template code relying on removed lit-html 2 behaviour has to be revisited. The package has no runtime dependencies beyond lit and lit-html, which keeps the dependency surface small and makes the upgrade boundary easy to see: if your template layer moves, haunted moves with it.

The licence is BSD-2-Clause, declared in both package.json and the LICENSE file. It is a permissive licence with a two-clause structure, which typically means redistribution in source or binary form is allowed provided the copyright notice and licence text are retained. This is not legal advice; read the LICENSE file in the repository for the exact wording and the disclaimer that accompanies it.

Editorial conclusion

Adopt haunted when you are already rendering with lit-html or hyperHTML and want hook-based state, effects and context with a web component wrapper you can register through customElements.define. Do not adopt it if you need a renderer it does not support, or if you want a framework that owns routing, data fetching and form handling: haunted is a hooks runtime, not an application framework. Before committing, verify the exact component() signature you need in the docs, confirm the lit-html version your app already bundles matches the ^3.0.0 range in package.json, and read the BSD-2-Clause licence text against your redistribution plans.

Frequently asked questions

What is haunted, and what does it do for web components?

Haunted implements React's Hooks API for standard web components, rendering with lit-html or hyperHTML. It provides a component() wrapper you pass to customElements.define, plus hooks such as useState, useEffect, useMemo, useReducer, useRef, useCallback, useContext, useLayoutEffect and useController.

How do I install haunted?

Install it from npm with npm install haunted, or import it directly in the browser from https://unpkg.com/haunted/haunted.js as the README's counter example does. Installing it pulls in lit and lit-html at ^3.0.0.

Which hooks does haunted support?

The README lists useCallback, useContext, useController, useEffect, useLayoutEffect, useMemo, useReducer, useRef and useState. Each has a page in the documentation site linked from the README.

Can I reuse React hooks from npm inside a haunted component?

The README states the hope is that you can reuse hooks available on npm by aliasing package names in your bundler's config. It does not document which packages work, so this is something to verify per dependency rather than assume.

What licence does haunted use?

Haunted is licensed under BSD-2-Clause, declared in package.json and included as the LICENSE file in the repository root.

Official sources

  1. Issues
  2. License: BSD-2-Clause
  3. matthewp/haunted on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/matthewp-haunted.svg)](https://hysenlabs.com/projects/matthewp-haunted)