microtastic
v0.0.64
Published
Small tooling package for pure ES6 browser development.
Readme
About
Microtastic is a tiny (~1,200 line) development environment that combines the best ideas from Snowpack's unbundled development workflow with signals-based reactivity inspired by libraries like SolidJS, Alpine.js, and Preact Signals. The result is a lightweight, opinionated toolchain for building browser applications in pure ES6 without the overtooling and dependency hell of complex build systems.
Like Snowpack, Microtastic uses Rollup to convert CommonJS and multi-file ES6 modules into single-file ES6 modules during development. These can be imported directly in the browser without rebundling on every change, enabling fast development cycles. For production builds, Rollup handles bundling with tree-shaking and code-splitting to create optimized output.
Microtastic includes reactive.js, a signals-based reactive state management library that brings together the fine-grained reactivity of SolidJS-style signals, the declarative data-attribute bindings of Alpine.js, and the template literal approach of libraries like Lit. This gives you a complete reactive development experience without heavy frameworks.
Features
- Lightweight: Only ~1,200 lines of code
- ES6 Native: Pure ES6 development without complex toolchains
- Fast Development: Unbundled development workflow with hot reload support
- Asset Management: Automatic copying of fonts, CSS, and other assets from
node_modules - Simple Dev Server: Lightweight development server for serving static files
- Production Ready: Optimized builds with tree-shaking and code-splitting
- Reactive Framework: Built-in signals-based reactive state management with fine-grained reactivity
- Advanced Debugging: Named signals, debug mode, and
peek()for non-tracking reads - Circular Dependency Detection: Prevents infinite loops in computed signals
- Code Quality: Biome linter and formatter installed by default
- Dev Container: VS Code devcontainer configuration included for consistent development environment
- Opinionated: Simple project structure and workflow
Tech Stack:
- JavaScript (ES6) with Rollup for bundling
- Signals-based reactivity (inspired by SolidJS, Alpine.js, Preact Signals)
- Minimal external dependencies
Quick Start
Bootstrap a New Application
- Generate a new npm package/project:
npm init- Install Microtastic as a dev dependency:
npm install microtastic --save-dev- Run
microtastic initto bootstrap the application template:
npx microtastic initThe init command creates a project structure with:
app/src/main.js- Your application entry pointapp/index.html- HTML template.microtastic- Configuration filebiome.json- Biome linter and formatter configuration.devcontainer/- VS Code devcontainer setup for consistent development environment- Adds necessary npm scripts to
package.json - Installs
@biomejs/biomeas a dev dependency for linting and code quality
You can add your code in app/src/ with main.js as the main entry point. Any other resources (CSS, images, etc.) can be added anywhere in the app/ directory.
Development
Microtastic has a built-in development server which can be started with:
npm run devThe dev server starts on localhost:8181 (configurable via .microtastic). With hot reload enabled (default), the browser automatically refreshes when files in the app/ directory change.
Since pure ES6 is used, you can open and debug applications in modern browsers that support ES6 modules. See Browser Compatibility for details.
Production Build
You can prepare the bundled application by running:
npm run prodThis will bundle and optimize your code and put the application ready to publish in the public/ folder.
Preparing Dependencies
Before running the dev server or production build, you need to prepare your dependencies. This converts CommonJS modules from node_modules into ES6 modules that can be imported in the browser:
npm run prepareOr directly:
npx microtastic prepThis command:
- Bundles all dependencies from
package.jsoninto ES6 modules - Places them in
app/src/dependencies/ - Copies assets defined in
assetCopy(see Asset Copying)
Note: The init command automatically adds a prepare script to your package.json that runs before npm install, so dependencies are prepared automatically when you install packages.
Development Environment
Microtastic includes a complete development environment setup out of the box:
VS Code Dev Container
The template includes a .devcontainer/ configuration for VS Code that provides:
- Consistent Environment: Node.js 22 in a Docker container
- Pre-configured Extensions: Biome and ES6 string HTML syntax highlighting
- Auto-formatting: Biome configured as the default formatter with auto-fix on save
- Port Forwarding: Development server port (8181) automatically forwarded
To use the dev container:
- Open the project in VS Code
- When prompted, click "Reopen in Container" (or use Command Palette: "Dev Containers: Reopen in Container")
- VS Code will build the container and install dependencies automatically
Biome Linter & Formatter
Biome is installed automatically during microtastic init and configured for:
- Linting: Code quality checks with recommended rules
- Formatting: Consistent code style (tabs, double quotes)
- Import Organization: Automatic import sorting on save (in dev container)
The biome.json configuration file is included in the template and targets files in app/src/ (excluding the dependencies/ directory).
Available Biome commands:
npm run check # Lint and check code
npx biome check # Run linter
npx biome format # Format codeIn the VS Code dev container, Biome automatically formats and organizes imports on save.
CLI Commands
Microtastic provides the following CLI commands:
microtastic init- Initialize a new project from templatemicrotastic prep- Bundle dependencies fromnode_modulesto ES6 modulesmicrotastic dev- Start the development servermicrotastic prod- Build production bundlemicrotastic version- Display version information
These commands are typically run via npm scripts (see below), but can also be executed directly with npx microtastic <command>.
NPM Scripts
The init command automatically adds these scripts to your package.json:
{
"scripts": {
"prepare": "microtastic prep",
"dev": "microtastic dev",
"dependencies": "microtastic prep",
"prod": "microtastic prod",
"format": "biome format --write .",
"check": "biome check ."
}
}npm run prepare- Prepares dependencies (runs automatically afternpm install)npm run dev- Starts the development servernpm run dependencies- Alias forpreparenpm run prod- Builds the production bundlenpm run format- Formats code with Biome (auto-installed)npm run check- Lints and checks code with Biome
Configuration
Microtastic Settings
You can create a .microtastic file in the root of your project and add and change the following configurations:
{
"genServiceWorker": false, // Experimental feature that generates an offline-mode service worker. Mainly written for private projects and will need additional code from the application side to work.
"minifyBuild": true, // If Rollup need to minimize the application
"serverPort": 8181, // Port the debug server is running on.
"hotReload": true // Enable hot reload in development server. Automatically reloads the page when files in the app directory change.
}Asset Copying
Microtastic can automatically copy assets (fonts, CSS files, images, directories, etc.) from node_modules to your app directory during the prep phase. Add an assetCopy array to your package.json:
{
"assetCopy": [
{
"source": "node_modules/@fontsource/raleway/files/raleway-latin-400-normal.woff2",
"dest": "app/fonts/raleway-latin-400-normal.woff2"
},
{
"source": "node_modules/prismjs/themes/prism.min.css",
"dest": "app/css/prism-themes/prism.min.css"
},
{
"source": "node_modules/some-package/assets",
"dest": "app/vendor/some-package-assets"
}
]
}Each asset entry requires:
- source: Path to the file or directory in
node_modules(relative to project root) - dest: Destination path in your app (relative to project root)
Supported operations:
- Files: Individual files are copied to the destination path
- Directories: Entire directories are copied recursively to the destination path
Assets are copied when running npm run prepare or microtastic prep. Destination directories are created automatically if they don't exist.
Browser Compatibility
Microtastic targets modern browsers that support ES6 modules. This includes:
- Chrome/Edge: 61+ (ES modules support)
- Firefox: 60+ (ES modules support)
- Safari: 10.1+ (ES modules support)
- Opera: 48+ (ES modules support)
For production builds, you may need to add polyfills for older browsers if you use modern JavaScript features. The development server works best with the latest versions of Chrome, Firefox, or any browser with full ES6 module support.
Reactive.js
Microtastic includes reactive.js, a lightweight signals-based reactive state management library with declarative binding. It provides everything you need to build reactive applications without heavy frameworks.
Quick Start:
import { Signals, Reactive, html, css } from './reactive.js';For detailed examples, see the Examples section below.
import { Signals, Reactive, html, css } from './reactive.js';Signals
Signals are reactive primitives that track dependencies and update subscribers automatically.
Signals.create(value, equals?, name?)
Creates a signal with an initial value. Optionally provide a custom equality function and a name for debugging.
const count = Signals.create(0);
const user = Signals.create({ name: "Alice", age: 30 });
// Custom equality for arrays
const items = Signals.create([], (a, b) =>
a.length === b.length && a.every((x, i) => x === b[i])
);
// Named signals for debugging
const counter = Signals.create(0, undefined, "userCounter");
console.log(counter.toString()); // "Signal(userCounter)"Signal Methods:
signal.get()- Read value (tracks dependencies in computed contexts)signal.peek()- Read value without tracking dependenciessignal.set(value)- Update valuesignal.update(fn)- Update using function:signal.update(n => n + 1)signal.subscribe(fn)- Subscribe to changes, returns unsubscribe functionsignal.once(fn)- Subscribe for one notification onlysignal.subscribeInternal(fn)- Internal subscription (doesn't call immediately)signal.toString()- Get readable string representation
Signals.computed(fn, name?)
Creates a computed signal that automatically tracks dependencies and recomputes when they change. Optionally provide a name for debugging. Includes circular dependency detection to prevent infinite loops.
const firstName = Signals.create("Alice", undefined, "firstName");
const lastName = Signals.create("Smith", undefined, "lastName");
const fullName = Signals.computed(
() => `${firstName.get()} ${lastName.get()}`,
"fullName"
);
// Automatically updates when firstName or lastName changes
fullName.subscribe(name => console.log(name)); // "Alice Smith"
// Use peek() to read without creating dependencies
const logValue = Signals.computed(() => {
const val = fullName.peek(); // No dependency created
console.log("Current value:", val);
return val;
});
// Clean up when done
fullName.dispose();
logValue.dispose();Circular Dependency Protection: Computed signals detect circular dependencies and throw descriptive errors:
// This throws: "Circular dependency detected: a -> b -> a"
const a = Signals.computed(() => b.get() + 1, "a");
const b = Signals.computed(() => a.get() + 1, "b");Signals.computedAsync(fn, name?)
Creates an async computed signal that handles asynchronous operations like API calls. The signal value is an object with { status, data, error, loading } properties. Automatically cancels previous executions when dependencies change.
const userId = Signals.create(1, undefined, "userId");
const userData = Signals.computedAsync(async (cancelToken) => {
const id = userId.get();
const response = await fetch(`/api/users/${id}`);
// Check if this execution was cancelled
if (cancelToken.cancelled) return null;
return response.json();
}, "userData");
// Access state properties
userData.subscribe(state => {
console.log(state.status); // "pending" | "resolved" | "error"
console.log(state.loading); // true | false
console.log(state.data); // resolved data or previous data
console.log(state.error); // error object if status is "error"
});
// When userId changes, previous fetch is cancelled automatically
userId.set(2);
// Clean up
userData.dispose();Cancellation: When dependencies change, the previous async execution is automatically cancelled via the cancelToken.cancelled flag. This prevents race conditions and ensures only the latest result is used.
Error Handling: Errors are captured in the state object. Previous data is preserved when errors occur, allowing graceful degradation.
Signals.batch(fn)
Batches multiple updates into a single update cycle for better performance.
Signals.batch(() => {
count.set(1);
count.set(2);
count.set(3);
// Subscribers only notified once after batch completes
});Debugging Features
Signal Names
Signals and computed signals can be named for better debugging:
const userCount = Signals.create(0, undefined, "userCount");
const doubled = Signals.computed(() => userCount.get() * 2, "doubled");
console.log(userCount.toString()); // "Signal(userCount)"
console.log(doubled.toString()); // "Signal(doubled)"Named signals appear in debug logs and error messages, making it easier to track down issues in complex reactive applications.
Debug Mode
Enable debug mode to log all signal updates and computed recalculations:
import { setDebugMode } from './reactive.js';
setDebugMode(true); // Enable debug logging
const count = Signals.create(0, undefined, "counter");
count.set(5); // Logs: [Reactive] Signal updated: [counter] 0 -> 5
const doubled = Signals.computed(() => count.get() * 2, "doubled");
count.set(10); // Logs: [Reactive] Computed updated: [doubled] 20Reading Without Tracking
Use peek() to read signal values without creating dependencies:
const count = Signals.create(0);
const doubled = Signals.computed(() => count.get() * 2);
// Read without tracking - won't recompute if doubled changes
const logger = Signals.computed(() => {
console.log("Current doubled value:", doubled.peek());
return count.get(); // Only depends on count
});This is useful for logging, debugging, or conditional logic where you don't want to create reactive dependencies.
HTML Templates
html (Tagged Template Literal)
Creates safe HTML with automatic XSS protection. All interpolated values are escaped by default.
const name = "Alice";
const userInput = "<script>alert('xss')</script>";
const template = html`
<div>
<h1>Hello, ${name}!</h1>
<p>${userInput}</p> <!-- Automatically escaped -->
</div>
`;Features:
- Automatic XSS protection via escaping
- Supports nested
htmltemplates - Returns object with
__safe: trueandcontentproperty
trusted(content)
Marks content as trusted (bypasses escaping). Use with caution!
import { html, trusted } from './reactive.js';
const safeHtml = trusted("<strong>Bold</strong>");
const template = html`<div>${safeHtml}</div>`;join(items, separator?)
Joins an array of items (which can include html templates) with optional separator.
import { html, join } from './reactive.js';
const items = [
html`<li>Item 1</li>`,
html`<li>Item 2</li>`,
html`<li>Item 3</li>`
];
const list = html`<ul>${join(items)}</ul>`;CSS-in-JS
css (Tagged Template Literal)
Creates scoped CSS styles with automatic class name generation. Styles are injected into the document head.
const buttonStyle = css`
background: blue;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
&:hover {
background: darkblue;
}
.child {
font-size: 12px;
}
`;
// Returns a class name like "s-abc123"
const button = html`<button class="${buttonStyle}">Click me</button>`;Features:
- Automatic scoping (styles prefixed with generated class)
&selector refers to the component root- Child selectors are automatically scoped
- Root-level properties are wrapped in the component class
- Styles are cached (same CSS returns same class name)
Reactive Bindings
Reactive.mount(element, fn)
Mounts a reactive template to an element. The function is called whenever dependencies change.
const count = Signals.create(0);
Reactive.mount(document.body, () => html`
<div>Count: ${count.get()}</div>
`);
// Returns { update } object to manually trigger updates
const { update } = Reactive.mount(element, fn);Manual Bindings
// Bind text content
Reactive.bindText(element, signal);
// Bind innerHTML with transformation
Reactive.bind(element, signal, (val) => html`<strong>${val}</strong>`);
// Bind attributes
Reactive.bindAttr(element, "href", signal);
Reactive.bindBoolAttr(element, "disabled", signal);
Reactive.bindClass(element, "active", signal);
// All return unsubscribe functions
const unsubscribe = Reactive.bindText(element, signal);Reactive.scan(rootElement, scope)
Scans an element tree for declarative data-* bindings and sets them up. Returns cleanup function.
const scope = {
count: Signals.create(0),
increment: () => count.update(n => n + 1)
};
const cleanup = Reactive.scan(document.body, scope);
// Later: cleanup();Declarative Bindings
Use data-* attributes in HTML for reactive bindings. Works with Reactive.scan():
Basic Bindings
<!-- Text content -->
<div data-text="count"></div>
<!-- InnerHTML (supports html templates, recursively scans children) -->
<div data-html="message"></div>
<!-- Show/hide element -->
<div data-visible="isVisible">Content</div>
<!-- Two-way form binding (works with signals that have .set()) -->
<input type="text" data-model="username" />Attribute Bindings
<!-- Any attribute -->
<a data-attr-href="url" data-attr-target="target">Link</a>
<!-- Boolean attribute (adds/removes) -->
<button data-bool-disabled="isDisabled">Submit</button>
<!-- Toggle CSS class -->
<div data-class-active="isActive">Item</div>Event Handlers
<!-- Event handler (called with event object, scope as this) -->
<button data-on-click="increment">Click me</button>
<input data-on-keydown="handleKeydown" />Element References
<!-- Creates reference in Component's this.refs -->
<input data-ref="usernameInput" />In Components:
// Access via this.refs
this.refs.usernameInput.value;Components
Class-based components with lifecycle management and automatic cleanup.
Basic Component
class Counter extends Reactive.Component {
state() {
return {
count: 0,
label: "Count"
};
}
styles() {
return css`
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
`;
}
template() {
return html`
<div>
<h2><span data-text="label"></span>: <span data-text="count"></span></h2>
<button data-on-click="increment">+</button>
<button data-on-click="decrement">-</button>
</div>
`;
}
increment() {
this.count.update(n => n + 1);
}
decrement() {
this.count.update(n => n - 1);
}
}
const counter = new Counter();
counter.mountTo("app");Component Lifecycle
Components follow a predictable lifecycle flow:
state()- Returns initial state (functions → computed, primitives → signals, existing signals preserved)init()- Called after state initialization, before rendering (optional) - ideal for creating computed/async signals that depend on staterender()- Creates and returns DOM element fromtemplate()withstyles()appliedmount()- Called after component is mounted to the DOM (optional) - use for side effects that need the DOM
Additional hooks:
styles()- Returns CSS class name (optional)template()- Returns HTML template (required)onCleanup()- Called during cleanup (optional)cleanup()- Manually cleanup subscriptions
Component Methods
this.signal(value)- Create a signalthis.computed(fn)- Create computed signal (auto-cleaned)this.computedAsync(fn)- Create async computed signal (auto-cleaned)this.effect(fn)- Run side effect when dependencies changethis.batch(fn)- Batch updatesthis.track(fn)- Track subscription for cleanupthis.on(target, event, handler, options?)- Add event listener (auto-cleaned)this.scan(element)- Scan element for bindings (usesthisas scope)this.render()- Render component to elementthis.mountTo(containerId)- Mount to container (replaces content)this.appendTo(containerId)- Append to containerthis.refs- Object with element references (fromdata-ref)
Examples
Simple Counter
import { Signals, Reactive, html } from './reactive.js';
const count = Signals.create(0);
const app = () => html`
<div>
<h1>Count: <span data-text="count"></span></h1>
<button data-on-click=${() => count.update(n => n + 1)}>
Increment
</button>
<button data-on-click=${() => count.update(n => n - 1)}>
Decrement
</button>
</div>
`;
Reactive.scan(document.body, { count });
Reactive.mount(document.body, app);Todo List
import { Signals, Reactive, html, join } from './reactive.js';
const todos = Signals.create([]);
const newTodo = Signals.create("");
const addTodo = () => {
if (newTodo.get().trim()) {
todos.update(list => [...list, {
id: Date.now(),
text: newTodo.get(),
done: false
}]);
newTodo.set("");
}
};
const toggleTodo = (id) => {
todos.update(list =>
list.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
};
const app = () => html`
<div>
<h1>Todos</h1>
<input
data-model="newTodo"
placeholder="New todo..."
data-on-keydown=${(e) => e.key === 'Enter' && addTodo()}
/>
<button data-on-click="addTodo">Add</button>
<ul>
${join(todos.get().map(todo => html`
<li>
<input
type="checkbox"
checked=${todo.done}
data-on-change=${() => toggleTodo(todo.id)}
/>
<span style="text-decoration: ${todo.done ? 'line-through' : 'none'}">
${todo.text}
</span>
</li>
`))}
</ul>
</div>
`;
Reactive.scan(document.body, { todos, newTodo, addTodo });
Reactive.mount(document.body, app);Component Example
import { Reactive, html, css } from './reactive.js';
class UserCard extends Reactive.Component {
constructor(userId) {
super();
this.userId = this.signal(userId);
this.user = this.computedAsync(async (cancel) => {
// This will be re-run when this.userId changes
const res = await fetch(`/api/users/${this.userId.get()}`);
if (cancel.cancelled) return; // Don't update if a new request has started
return res.json();
});
}
state() {
return { expanded: false };
}
styles() {
return css`
border: 1px solid #ddd;
padding: 16px;
margin: 8px;
border-radius: 8px;
&.expanded {
background: #f5f5f5;
}
`;
}
template() {
return html`
<div data-class-expanded="expanded">
${() => {
const state = this.user.get();
if (state.loading) return html`<h3>Loading...</h3>`;
if (state.error) return html`<h3>Error: ${state.error.message}</h3>`;
const user = state.data;
return html`
<h3>${user.name}</h3>
<div data-visible="expanded">
<p>Email: ${user.email}</p>
</div>
<button data-on-click="toggle">
${this.computed(() => this.expanded.get() ? 'Collapse' : 'Expand')}
</button>
`;
}}
</div>
`;
}
toggle() {
this.expanded.update(v => !v);
}
}
// Usage
const userIds = [1, 2];
userIds.forEach(id => {
const card = new UserCard(id);
card.appendTo("app");
});Best Practices
- Use signals for reactive state - Prefer
Signals.create()over plain variables. - Name your signals - Provide a name for signals and computed signals (e.g.,
Signals.create(0, undefined, "counter")) for easier debugging. - Batch multiple updates - Use
Signals.batch()to avoid intermediate renders. - Clean up subscriptions - Always call cleanup functions or use Components for automatic cleanup.
- Use computed for derived state - Create computed signals for values derived from others.
- Handle async with
computedAsync- For data fetching, usecomputedAsyncfor built-in state management and cancellation. - Use
peek()to avoid dependencies - Inside a computed, usesignal.peek()to read a value without creating a dependency. - Prefer declarative bindings - Use
data-*attributes withReactive.scan(). - Component state management - Use
state()method to automatically convert values. - CSS scoping - Use
csstemplate tag for component-scoped styles. - HTML safety - Always use
htmltemplate tag for automatic XSS protection. - Refs for DOM access - Use
data-refandthis.refsinstead ofquerySelector. - Effect cleanup - Use
this.effect()in components for side effects that are automatically cleaned up. - Conditional rendering with functions - Embed functions in
htmltemplates for dynamic rendering logic.
