Batteries-included reactivity in a kilobyte-sized package. Seidr brings type-safe components and SSR to vanilla JavaScript/TypeScript with build step optional.
Seiðr - Old Norse for "magic of weaving fate and causality."
- Features
- When to Use Seidr
- Installation
- Quick Start
- Conceptual Overview
- Core Concepts
- API Reference
- Server-Side Rendering
- Animation
- Performance
- Browser Support
- 🔋 Batteries Included - SSR engine and Global State management
- 🪄 Reactive Bindings - Observable to DOM attribute binding
- 🎯 Type-Safe Props - TypeScript magic for reactive HTML attributes
- 🔧 Functional API - Simple, composable functions for DOM creation
- 📦 Tiny Footprint
- Hello World: 3.0KB (brotli)
- TodoMVC: 4.7KB (brotli)
- SSR Enabled: 6.4KB (brotli) - This includes reactivity, DOM bindings, and SSR capability; no compiler or runtime layering required.
- Tree-shakable: Import only what you need
- ⚡ Zero Dependencies - Pure TypeScript, build step optional
- 🏗️ Ready for SSR - Automatic state capture and hydration
Seidr is designed for developers who value control, correctness, and deliberate engineering. It's not the right tool for every project.
Small to Medium-Sized Applications
- SPAs where bundle size matters
- Interactive widgets and components
- Browser extensions with size constraints
- Progressive enhancement of server-rendered pages
Projects That Benefit From
- Direct DOM manipulation without virtual DOM overhead
- Explicit lifecycle management (no hidden re-renders)
- Type-safe reactive bindings
- Build step optional (or minimal build setup)
- Full TypeScript support with advanced type inference
Teams That Prefer
- Functional programming patterns over class hierarchies
- Explicit over implicit (no magic, just functions)
- Understanding how their tools work internally
- Control over performance characteristics
You Need
- A rich ecosystem of pre-built components (use React, Vue)
- Complex routing and state management out of the box (use Next.js, SvelteKit)
- Learning resources for junior developers (use more mainstream frameworks)
- Built-in dev tools and debugging experiences (use React DevTools, Vue DevTools)
Your Team
- Is primarily focused on rapid prototyping over long-term maintainability
- Prefers convention over configuration
- Doesn't want to think about cleanup and memory management
- Needs extensive community support and third-party integrations
Seidr embraces fact-based tradeoffs:
- No virtual DOM → Faster updates, more predictable performance, but manual DOM management
- One class only → Simpler mental model, but you must understand the
Seidrobservables deeply - Explicit over Implicit → You control the reactivity graph manually if needed
This is infrastructure for developers who want a "batteries included" framework (SSR, Global State) without the bloat of a Virtual DOM or build-step requirement.
npm install @fimbul-works/seidrOr using your preferred package manager:
yarn add @fimbul-works/seidr
pnpm install @fimbul-works/seidrimport { mount, Seidr } from '@fimbul-works/seidr';
import { $div, $button, $span } from '@fimbul-works/seidr/html';
const Counter = () => {
const count = new Seidr(0);
const disabled = count.as(value => value >= 10);
return $div({
className: 'counter',
style: 'padding: 20px; border: 1px solid #ccc;'
}, [
$span({ textContent: count }), // Automatic reactive binding
$button({
textContent: 'Increment',
disabled, // Reactive boolean binding
onclick: () => count.value++
}),
$button({
textContent: 'Reset',
onclick: () => count.value = 0
})
]);
};
mount(Counter, document.body);Before diving into the details, it helps to understand Seidr's mental model. This section walks through the complete flow from state to reactivity to cleanup.
1. Reactive State (Seidr)
- The only class in Seidr
- Holds a value and notifies listeners when it changes
- Think "reactive variable" not "state management system"
2. Bindings
- Connect reactive state to DOM properties
- Automatically update when the observable changes
- No manual DOM manipulation needed
3. Cleanup
- Every binding returns a cleanup function
- Components track their bindings and clean up automatically
- No memory leaks when components are destroyed
Let's build a simple search filter step by step:
import { Seidr } from '@fimbul-works/seidr';
// Create observables with initial values
const searchQuery = new Seidr('');
const items = new Seidr([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' }
]);// Create derived observable that filters based on search
const filteredItems = Seidr.merge(() => {
const query = searchQuery.value.toLowerCase();
return query
? items.value.filter(item => item.name.toLowerCase().includes(query))
: items.value;
}, [items, searchQuery]);What happens: filteredItems is now a reactive value that automatically updates whenever:
itemschanges (if you add/remove items)searchQuerychanges (when user types)
import { $input, Seidr } from '@fimbul-works/seidr/html';
const searchQuery = new Seidr('');
// Create input bound to search query
const searchInput = $input({
type: 'text',
placeholder: 'Search...',
// Two-way binding: observable -> DOM -> observable
value: searchQuery,
oninput: (e) => (searchQuery.value = e.target.value),
});What happens:
- Initial render: input shows
searchQuery.value(empty string) - User types "app":
oninputfires →searchQuery.valuebecomes "app" filteredItemsautomatically recomputes →[{ id: 1, name: 'Apple' }]
import { Seidr, List } from '@fimbul-works/seidr';
import { $input, $div, $ul, $li } from '@fimbul-works/seidr/html';
const SearchApp = () => {
const searchQuery = new Seidr('');
const items = new Seidr([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' }
]);
const filteredItems = Seidr.merge(() => {
const query = searchQuery.value.toLowerCase();
return query
? items.value.filter(item => item.name.toLowerCase().includes(query))
: items.value;
}, [items, searchQuery]);
const searchInput = $input({
type: 'text',
placeholder: 'Search...',
value: searchQuery,
oninput: (e) => (searchQuery.value = e.target.value),
});
return $div({}, [
searchInput,
$ul({}, [
// List component with key-based diffing
List(
filteredItems,
(item) => item.id,
(item) => $li({ textContent: item.as(i => i.name) })
)
])
]);
};What happens:
Listcomponent tracksfilteredItemsobservable- When
filteredItemschanges:- Diff algorithm finds changed/added/removed items
- Updates only affected DOM elements
- No full re-render
import { mount } from '@fimbul-works/seidr';
const cleanup = mount(SearchApp, document.body);
// SearchApp is now interactive
// - User types → searchQuery updates → filteredItems recomputes → list updates
// - All reactive bindings work automatically
// When done, cleanup everything:
cleanup();
// - All reactive bindings disconnected
// - All event listeners removed
// - All DOM elements removed
// - No memory leaksThink in Graphs, Not Trees
searchQuery (root)
│
▼
items (root) → filteredItems (derived) ──▶ list rendering
- Root observables (
searchQuery,items) hold actual data - Derived observables (
filteredItems) transform data - Bindings connect observables to DOM
The Flow:
- User action changes root observable
- Change propagates through derived observables
- Bindings update DOM elements directly
- No virtual DOM. No component re-execution. No tree reconciliation. Only direct propagation through a dependency graph
Seidr follows a "Push-Based" reactive model. Unlike React (which pulls updates by re-rendering) or Svelte (which compiles reactivity into statements), Seidr pushes updates directly to the specific DOM properties that need them.
[ User Action ]
│
▼
[ Root Observable (Seidr) ] ──▶ [ Cleanup Tracking (Component) ]
│
▼
[ Derived Observables (Seidr.merge or instance.as) ]
│
▼
[ DOM Bindings (props) ] ──▶ [ Real DOM Updates ]
What this means for you:
- Zero re-renders: A component function only ever runs once.
- O(1) updates: Changing a value updates only the specific bound nodes, regardless of tree size.
- Predictable memory: You decide when things are created and destroyed via scopes.
Benefits:
- Predictable: You know exactly what updates when
- Efficient: Only changed DOM elements update
- Simple: One-way data flow, no cycles
- Type-safe: TypeScript tracks observable types through derivations
- Seidr instances are the source of truth - all state flows from them
- Derived values are automatic - no manual recomputation needed
- Cleanup is automatic - components track and clean up their bindings
- Direct DOM manipulation - no virtual DOM means predictable performance
This model gives you control without complexity. You understand exactly what's happening, but you don't have to manage the details manually.
Seidr is built around Seidr - a reactive observable that manages state and automatically updates bound DOM elements. All other features are composable utility functions.
State is stored in Seidr observables. Create them, pass them to element props, and Seidr handles the rest.
import { Seidr } from '@fimbul-works/seidr';
import { $input } from '@fimbul-works/seidr/html';
const disabled = new Seidr(false);
const input = $input({ disabled });
disabled.value = true; // Input instantly becomes disabledLearn more: Seidr
Transform observables with .as() and .merge() for derived values that update automatically.
const count = new Seidr(0);
const doubled = count.as(n => n * 2);
const message = count.as(n => n > 5 ? 'Many!' : `Count: ${n}`);Learn more: instance.as() | Seidr.merge()
Seidr components are simple functions that return UI elements. They are lightweight, easy to test, and have full access to Seidr's reactivity and lifecycle management.
import { Seidr, useScope } from '@fimbul-works/seidr';
import { $div, $span, $button } from '@fimbul-works/seidr/html';
const UserProfile = ({ name, initialAge = 30 }) => {
const age = new Seidr(initialAge);
// Track custom cleanup logic
useScope().onUnmount(() => console.log('Profile destroyed'));
return $div({ className: 'user-profile' }, [
$span({ textContent: name }),
$span({ textContent: age.as(a => `Age: ${a}`) }),
$button({
textContent: 'Birthday',
onclick: () => age.value++
})
]);
};The Magic: When you mount a function using
mount(),List(),Show(),Switch(),Safe(), andSuspense(), Seidr automatically provides a reactive scope. This meansuseScope().onUnmount()and automatic cleanup work perfectly in plain functions!
If you need to create a reusable component factory that can be passed around as a single unit, or if you need to manually instantiate a component instance, you can use the component() wrapper:
import { component } from '@fimbul-works/seidr';
// Returns a factory function
const CounterFactory = component(({ start = 0 }) => {
const count = new Seidr(start);
return $button({
textContent: count,
onclick: () => count.value++
});
});
// Usage
const counter1 = CounterFactory({ start: 10 }); // Returns a SeidrComponent instance
mount(counter1, container);Components accept parameters for configuration and initial state through plain function arguments:
const Counter = ({ initialCount = 0, step = 1 } = {}) => {
const count = new Seidr(initialCount);
const disabled = count.as(value => value >= 10);
return $div({ className: 'counter' }, [
$span({ textContent: count.as(n => `Count: ${n}`) }),
$button({
textContent: `+${step}`,
disabled,
onclick: () => count.value += step
})
]);
};
// Usage
mount(Counter, document.body);Key Points:
- Props are passed when creating the component (not when mounting)
- Each component instance has its own isolated state
- Props can include initial values, configuration, or callbacks
- Destructuring with defaults (
= {}) makes props optional
Learn more: component() | Manual bindings
Seidr automatically cleans up reactive bindings created within a component. However, for external resources like intervals, event listeners, or network connections, you should use the useScope().onUnmount() hook to track cleanup and avoid memory leaks.
// ❌ WRONG: Leaks memory when component is destroyed
const BadComponent = () => {
const count = new Seidr(0);
// This interval keeps running even after component is unmounted!
setInterval(() => count.value++, 1000);
return $div({ textContent: count });
};
// ✅ CORRECT: Cleanup tracked automatically
const GoodComponent = () => {
const count = new Seidr(0);
const interval = setInterval(() => count.value++, 1000);
useScope().onUnmount(() => clearInterval(interval));
return $div({ textContent: count });
};Create reusable element creators with default props:
import { $factory } from '@fimbul-works/seidr';
// Create custom factories
const $primaryButton = $factory('button', { className: 'btn btn-primary' });
const submitButton = $primaryButton({ textContent: 'Submit' });Learn more: $factory()
For complete API documentation with all methods, parameters, and examples, see API.md.
Seidr provides SSR support with automatic state capture and client-side hydration. This allows you to render your Seidr applications on the server and make them interactive on the client.
For more information, see SSR.md.
For high-performance animations, I recommend flaedi, my sub-1KB promise-based animation engine. It is designed to work seamlessly with Seidr observables.
npm install @fimbul-works/flaediimport { Seidr } from '@fimbul-works/seidr';
import { tween, easeOutExpo } from '@fimbul-works/flaedi';
const opacity = new Seidr(0);
// Smoothly animate observable value from 0 to 1
await tween(opacity, 'value', 1, 500, easeOutExpo);Seidr's direct DOM manipulation approach offers several performance advantages:
Only changed elements are updated with no virtual DOM diffing overhead.
const count = new Seidr(0);
const display = $span({ textContent: count });
// Only the span's textContent is updated, nothing else
count.value++;Unlike React/Vue, Seidr doesn't need to diff component trees. Updates go straight to the DOM.
- React TodoMVC: ~60KB (React + ReactDOM)
- Vue3 TodoMVC: ~25KB (Vue runtime)
- SolidJS TodoMVC: ~6KB (SolidJS runtime)
- Seidr TodoMVC: ~5.3KB (Seidr core runtime)
Note on Tree-Shaking: The client-side bundle size is ~7.0KB including the core library and SSR hydration engine. If your project only uses core reactivity and elements, your baseline bundle will be significantly smaller.
Seidr works in all modern browsers:
- ✅ Chrome/Edge 90+
- ✅ Firefox 88+
- ✅ Safari 14+
- ✅ Opera 76+
Requires:
- ES6 Class support
- ES6 Map/Set support
MIT License - See LICENSE file for details.
Built with ⚡ by FimbulWorks