Skip to content

Repository files navigation

flub.js

flub is a tiny DOM microframework. Components construct real DOM once; signals update only the text, properties, styles, and keyed regions that depend on them. There is no virtual DOM, compiler, or browser-runtime dependency.

v2 is currently an alpha. The v1 setState rerendering API has been removed because replacing component roots cannot preserve DOM or component identity.

Install

npm install flub.js@next
import { Btn, Column, Row, Text, mount, signal } from "flub.js";

mount(document.body, Counter);

function Counter() {
  const count = signal(0);

  return Column({
    gap: 16,
    children: [
      Text({ as: "h1", children: () => `Count: ${count()}` }),
      Row({
        gap: 8,
        children: [
          Btn({ children: "−", onClick: () => count.update((n) => n - 1) }),
          Btn({ children: "+", onClick: () => count.update((n) => n + 1) }),
        ],
      }),
    ],
  });
}

mount() returns a disposal function that removes the mounted range and cleans up every owned effect and event listener.

Mental model

Components are ordinary functions and are called once. Call nested components normally:

Div({ children: Heading() });

A function in a DOM value position is a reactive accessor:

const name = signal("Ada");

Text({ children: () => `Hello, ${name()}` });
Input({ value: name, disabled: () => name() === "" });

This explicit distinction lets flub update a text node or DOM property directly instead of rerunning the component and diffing a second tree.

Reactive state

const first = signal("Ada");
const last = signal("Lovelace");
const fullName = computed(() => `${first()} ${last()}`);

effect(() => console.log(fullName()));

batch(() => {
  first.set("Grace");
  last.set("Hopper");
});
  • signal(value) creates a callable accessor with .set() and .update().
  • computed(fn) is lazy and cached.
  • effect(fn) runs immediately, then once per asynchronous update batch.
  • batch(fn) groups related writes.
  • untrack(fn) reads signals without adding dependencies.

Dynamic structure

Use Show and For when the DOM structure itself changes:

Show(
  () => session() !== null,
  () => Dashboard(),
  () => Login(),
);

For(
  todos,
  (todo, index) =>
    Div({
      "data-index": index,
      children: () => todo().title,
    }),
  { key: (todo) => todo.id },
);

Show disposes the inactive branch. For moves existing keyed nodes during a reorder and gives each item its own reactive value and cleanup scope.

Errors and asynchronous data

ErrorBoundary contains failures from components, reactive DOM bindings, effects, and lifecycle hooks. Resetting disposes the failed subtree and creates a fresh owner:

ErrorBoundary({
  children: () => Dashboard(),
  fallback: (error, reset) =>
    Btn({ children: `Retry: ${error.message}`, onClick: reset }),
});

resource(source, fetcher) tracks loading, errors, and the latest value. It aborts the previous request when its source changes or its owner is disposed, and ignores late responses:

const users = resource(userQuery, async (query, { signal }) => {
  const response = await fetch(`/api/users?q=${query}`, { signal });
  if (!response.ok) throw new Error(`Request failed (${response.status})`);
  return response.json();
});

Show(users.loading, () => Text({ children: "Loading…" }));
For(
  () => users.value() ?? [],
  (user) => Text({ children: user().name }),
);

Reading resource.value() throws a stored request error, so a reactive consumer automatically enters its nearest ErrorBoundary. Read resource.error() to build an inline error state instead. Resources also provide .refetch(), .mutate(value), and .dispose().

Scheduled errors without a boundary are rethrown in a microtask by default. Hosts can replace that behavior with setUnhandledErrorHandler(handler) for logging or crash reporting; the function returns a restore callback.

Context and portals

Context values follow the owner tree and avoid global singletons or prop drilling. Providers create their own cleanup scope, and the nearest provider wins:

const Theme = createContext("paper");

provide(Theme, "night", () => App());

function Toolbar() {
  const theme = useContext(Theme);
  return Text({ children: `Theme: ${theme}` });
}

Portal renders a factory into another element or shadow root without changing its logical owner. Portalled components retain context, lifecycle cleanup, and their nearest error boundary:

Portal({
  target: document.body,
  children: () => Modal(),
});

Lifecycle

function Clock() {
  const time = signal(new Date());
  const timer = setInterval(() => time.set(new Date()), 1000);

  onMount(() => console.log("mounted"));
  onCleanup(() => clearInterval(timer));

  return Text({ children: () => time().toLocaleTimeString() });
}

Use component(Clock) when a nested component needs a distinct lifecycle scope. Dynamic Show branches and For items already receive their own scopes.

DOM behavior

element(tag, props) and the element helpers use native DOM semantics:

  • Boolean properties such as disabled and checked are not stringified.
  • value, selected, and other DOM properties update as properties.
  • aria-*, data-*, and unknown values remain attributes.
  • class supports strings, arrays, and conditional objects.
  • style supports strings and objects; numeric dimensional values use pixels.
  • onClick, onclick, and on:custom-event use addEventListener.
  • String event handlers and raw innerHTML are rejected.
  • Svg(tag, props) creates namespace-correct SVG elements.

Every modern HTML tag has a typed PascalCase factory, from A and Article through Table, Textarea, and Video. LinkElement, MapElement, and ObjectElement use explicit names to avoid ambiguous imports. Build a factory for a tag once when authoring your own primitive:

import { createElementFactory } from "flub.js/elements";

const Card = createElementFactory("article");
Card({ class: "card", children: "Native, typed, and reusable." });

Components

The component entry point is an unstyled accessibility and composition layer:

  • Layout: Box, Flex, Row, Column, Grid, Cluster, Container, Center, Spacer, and AspectRatio.
  • Typography: Text, Heading, CodeBlock, Separator, and VisuallyHidden.
  • Forms: Btn, IconButton, Field, common typed inputs, TextareaInput, SelectInput, Checkbox, Radio, Switch, and ManagedForm.
  • Collections: List, OrderedList, DescriptionList, and Breadcrumbs.
  • Disclosure: Disclosure, ModalDialog, Alert, and Status.

Components add native semantics, ownership, and useful accessibility wiring. They intentionally ship without theme CSS, so application styles remain in control.

Use attr:name or prop:name to explicitly select attribute or property behavior for unusual custom elements.

Package entry points

import { signal, mount } from "flub.js/core";
import { Row, Text } from "flub.js/components";
import { Article, Button } from "flub.js/elements";

The package is ESM-only and includes TypeScript declarations and source maps. Its intended scope is client-rendered widgets, islands, internal tools, and small-to-medium browser applications. Routing, full hydration, and a JSX compiler are not v2 goals.

Optional static rendering

Static HTML is an opt-in build feature. Existing applications keep using mount() with no configuration or additional dependency. To render selected pages during a Vite build, install the companion package:

npm install -D @flub/static
// vite.config.ts
import { flubStatic } from "@flub/static/vite";

export default {
  plugins: [
    flubStatic({
      pages: { "index.html": "src/page.ts" },
    }),
  ],
};

Place <!--flub-static--> in the HTML body and default-export a component from src/page.ts. The build writes its initial DOM into the page. Reactive values, Show, and For render their initial state; effects, refs, and event listeners remain browser-only.

Static output does not require hydration. Use enhance(root, setup) to attach owned listeners to existing markup, or mount() small interactive widgets as independent islands. Disposing an enhancement runs its registered lifecycle cleanup without replacing the static DOM.

Development

npm install
npx playwright install chromium
npm run dev
npm run test:guide
npm run test:conformance
npm run check

The complete flub-powered field guide is available at /examples/guide/. It is written in TypeScript, statically rendered with @flub/static, and progressively enhanced with flub. It includes a filterable element catalog, live component examples, and component-specific prop references. npm run check runs strict typechecking across the library, tests, and TypeScript examples, plus linting, formatting verification, unit, browser, and guide smoke tests, package-contract validation, and the bundle-size budget.

See the design contract for the architectural boundaries. See the conformance contract for lifecycle workloads, garbage-collection checks, and committed performance budgets.

Used by

Contributors

Languages