ks-fwork
v4.7.2
Published
Demo Frontend Framework
Readme
ks-fwork
A small, vanilla-JavaScript frontend framework built from scratch — no React, Vue, or any other rendering library underneath. It gives you a virtual DOM with keyed reconciliation, a component system, client-side hash routing, a shared-state store with persistence, and a native-events-based event system, all wired together by a single createApp entry point.
This document covers the framework's architecture, how to install and start using it, every feature with runnable examples, and the best practices and performance decisions behind it.
Built following the design taught in Build a Frontend Web Framework (From Scratch) by Ángel Sola Orbaiceta — the virtual DOM, mount/patch pipeline, dispatcher-based pub/sub, and keyed list diffing all follow that book's approach, extended here with a store layer, HTTP wrapper, hash router, and declarative event delegation.
Table of contents
Architecture & design principles
ks-fwork is a framework, not a library: instead of a collection of functions you call from your own main(), you hand the framework your component definitions and it owns the render loop, the event loop, and the application lifecycle. You write defineComponent({...}) and class MyStore extends Store; the framework calls into your code (render(), onMounted(), store subscribers), not the other way around. That inversion of control is the dividing line between a library and a framework, and it's deliberate throughout the design.
Virtual DOM. Every component's render() returns a plain object tree (a "vNode") built with h(), not real DOM nodes. On first mount, mountDOM walks that tree and creates real elements. On every subsequent render, patchDOM diffs the new vNode tree against the previous one and applies the minimal set of DOM operations needed — it never tears down and rebuilds the whole page on a state change.
Keyed reconciliation. When a component renders a list, giving each item a stable key prop lets the diffing algorithm (arraysDiffSequence in utils/arrays.js) tell the difference between "this item moved" and "this item was removed and a different one was added." Without keys, the framework falls back to matching by type and position, which is fine for static content but causes unnecessary DOM churn for reorderable lists.
Components as classes, defined declaratively. defineComponent({ state, render, onMounted, onUnmounted, ...methods }) returns a real class under the hood, but you never write class or extends yourself for components — you describe behavior as a plain configuration object, and the framework builds the class. state() produces the initial state from props, render() returns a vNode tree, and any other keys become instance methods you can call from your own code (this.someMethod()).
One-way data flow, two ways up. Data flows down through props (h(Child, { someProp })). Children never mutate a parent's data directly. Reporting back up happens in one of two ways, and the framework supports both deliberately:
- Component-level delegation — a child calls
this.emit('eventName', payload), and a parent handlingon: { eventName: handler }receives it. The parent owns the decision of what the event means; the child just reports what happened. This is the framework's primary answer to "event handling delegated to parent elements." - Native DOM delegation — the exported
delegate(rootEl, eventName, selector, handler)primitive attaches one real listener to a container and resolves which descendant triggered it viaevent.target.closest(selector), exactly the classic vanilla-JS delegation pattern. See Events for both, side by side, in the same real feature.
Real native events, not synthetic ones. Some frameworks (React, historically) wrap native events in a synthetic event system and attach a single listener at the app root, so your onClick handler never touches a real Event object. ks-fwork doesn't do this: on: { click: handler } attaches a real addEventListener call, and your handler receives the real, unmodified native Event. This is why native patterns like DOM delegation and <form method="dialog"> behave exactly as they would in plain HTML — the framework never gets in the way of the platform.
Shared application context. createApp(RootComponent, props, { router, stores }) threads a single appContext object (containing your router instance and your named stores) down through every component in the tree via setAppContext. Any component can declare stores: ['todoStore'] and get automatic, lifecycle-managed access — no manual prop drilling required to reach shared state from a deeply nested component.
Installation
npm install ks-fworkks-fwork ships as an ES module ("type": "module") with a single bundled entry point (dist/ks-fwork.js). Its only runtime dependency is fast-deep-equal, a general-purpose comparison utility, not a frontend framework or rendering library — the framework itself (virtual DOM, diffing, components, router, store, events) is implemented from scratch.
Getting started
A minimal app: one component, mounted to the page.
// main.js
import { createApp, defineComponent, h } from 'ks-fwork';
const App = defineComponent({
state() {
return { count: 0 };
},
render() {
const { count } = this.state;
return h('div', {}, [
h('p', {}, [`Count: ${count}`]),
h('button', {
on: { click: () => this.updateState({ count: count + 1 }) },
}, ['Increment']),
]);
},
});
createApp(App).mount(document.getElementById('app'));That's the whole surface area needed to get something on screen: defineComponent describes behavior, h builds the tree render() returns, updateState triggers a re-render, and createApp(...).mount(...) bootstraps everything into a real DOM element. Everything else in this document — routing, shared stores, HTTP, events — layers on top of this same shape.
Features
Elements and components
h(tag, props, children) creates an element vNode; tag can be a string (a real HTML tag) or a component:
h('div', { className: 'card' }, [
h('h1', {}, ['Title']),
h(SomeComponent, { someProp: 42 }),
]);Elements nest naturally — children is just an array, and it can mix strings (auto-converted to text nodes), elements, and other components. hFragment([...]) groups multiple top-level nodes without an extra wrapping element (useful when a component's render() needs to return several siblings). hSlot() marks where a caller's children should be inserted into a component's own markup — see RouterLink, which renders whatever children you pass it inside its own <a>:
export const Card = defineComponent({
render() {
return h('div', { className: 'card' }, [
h('h2', {}, [this.props.title]),
hSlot(), // caller's children land here
]);
},
});
h(Card, { title: 'Hello' }, [
h('p', {}, ['This paragraph is the slot content.']),
]);Components are reusable by construction: a defineComponent definition is just a plain object describing behavior, so the same component can be instantiated many times with different props (see TodoItem, instantiated once per todo in TodoList).
State
Every component can define its own local state(), seeded from its initial props:
defineComponent({
state(props) {
return { text: '' };
},
render() {
const { text } = this.state;
// ...
},
});this.updateState(partial) shallow-merges partial into the current state and triggers a re-render (a diff against the previous render, not a full rebuild). State is local to the component instance — for state that needs to be shared across components or across pages, use a Store.
Store — shared & persisted state
Store is a base class you extend for each piece of shared application state:
import { Store } from 'ks-fwork';
export class TodoStore extends Store {
constructor() {
super(
{ todos: [] },
{ persistKey: 'todo-store', persistenceEnabled: true }
);
}
get todos() {
return this.state.todos;
}
addTodo(text) {
const todo = { id: crypto.randomUUID(), text, completed: false };
this.updateState({ todos: [...this.todos, todo] });
}
}
export const todoStore = new TodoStore();persistKey + persistenceEnabled make the store's state survive page reloads and browser restarts automatically, backed by localStorage — this is how the framework satisfies "stores and updates application state between sessions." Toggle persistence at runtime with setPersistenceEnabled(bool).
Sharing state between elements and pages. Register your store instances once, at app bootstrap:
createApp(RootComponent, {}, {
router,
stores: { todoStore, settingsStore },
}).mount(document.getElementById('app'));Any component, anywhere in the tree, can then opt in with the declarative stores option:
export const Home = defineComponent({
stores: ['todoStore', 'settingsStore'],
render() {
const { todoStore, settingsStore } = this.appContext.stores;
// reads are always live — this re-renders automatically on store changes
},
});Declaring stores: [...] does two things automatically, for every component that uses it: it subscribes to each named store on mount (triggering a re-render whenever that store's state changes) and unsubscribes on unmount. You never write manual onMounted/onUnmounted subscription code — this is what makes the same store usable from a component on the home page and a completely different component on a settings page, with both staying in sync for free.
Routing & URL-driven state
HashRouter drives client-side, hash-based routing:
import { HashRouter } from 'ks-fwork';
export const router = new HashRouter([
{ path: '/', component: Home, meta: { title: 'Home' } },
{ path: '/users/:id', component: UserPage, meta: { title: 'User' } },
{ path: '/settings', component: Settings, meta: { title: 'Settings' } },
{
path: '/protected',
component: Protected,
meta: { title: 'Protected' },
beforeEnter: async () => settingsStore.isAuthenticated || '/',
},
{ path: '*', component: NotFound, meta: { title: 'Not found' } },
]);Pass the router into createApp, then render a RouterOutlet where the matched page should appear, and use RouterLink for in-app navigation instead of plain <a> tags (it prevents a real page navigation and calls the router directly):
h(RouterOutlet);
h(RouterLink, { to: '/users/42' }, ['View user']);Every route's component receives params (from :id-style segments), query (from ?key=value query strings), and meta as props automatically — read them the same way you'd read any other prop:
export const UserPage = defineComponent({
render() {
const { id } = this.props.params;
const { title } = this.props.meta;
// ...
},
});beforeEnter guards control access to a route: return false to block navigation, a path string to redirect elsewhere, or anything else to allow it through.
The application state changes based on the URL, and the URL changes based on application state — both directions are supported. A filter toggle backed by the URL's query string looks like this:
h(RouterLink, { to: '/?filter=active' }, ['Active']);
h(RouterLink, { to: '/?filter=completed' }, ['Completed']);
// In the page component:
render() {
const { filter } = this.props.query;
const visibleTodos = filter === 'active' ? todoStore.active
: filter === 'completed' ? todoStore.completed
: todoStore.todos;
}Clicking a filter link changes the URL, which changes this.props.query, which changes what's rendered — state and URL stay in sync in both directions.
Styles and attributes
className (string or array) and style (an object of CSS properties) are handled specially by the attribute system; every other prop is set directly on the element:
h('div', {
className: 'card active', // or: className: ['card', 'active']
style: { color: 'red', fontSize: '14px' },
id: 'my-card',
'data-testid': 'card-1', // data-* attributes are set via setAttribute
disabled: true,
}, [...]);On patch, only what actually changed is touched: classes are diffed and added/removed individually via classList, styles are diffed key by key, and attributes are diffed and only the added/removed/updated ones are written — an unrelated attribute doesn't get rewritten just because a sibling attribute changed.
Events
Registering listeners. Any element or component can declare event listeners under on:, and they're wired up as real addEventListener calls the moment the element is mounted:
h('button', {
on: { click: () => this.doSomething() },
}, ['Click me']);Preventing default behavior and stopping propagation can be declared inline, without touching the event object yourself, by passing a config object instead of a plain function:
h('form', {
on: {
submit: {
handler: () => this.submitForm(),
preventDefault: true,
},
},
}, [...]);
h('input', {
type: 'checkbox',
on: {
click: {
handler: () => this.emit('complete', id),
stopPropagation: true,
},
},
});This is a thin layer on top of addEventListener, not a replacement for it — the underlying listener is real, the event object passed to your handler is the real, unmodified native Event, and plain function handlers (on: { click: fn }) keep working exactly as before. preventDefault/stopPropagation can be static booleans or computed from state/props at render time, letting you toggle the behavior at runtime (see the example app's Settings page, which lets you flip these on and off live to see the effect).
Event handling delegated to parent elements is supported two different ways, deliberately:
Component-level delegation — the idiomatic framework convention. A child emits, a parent decides:
// Child
this.emit('remove', id);
// Parent
h(TodoItem, { on: { remove: (id) => todoStore.removeTodo(id) } });Native DOM delegation — a lower-level primitive for when you want one real listener handling many descendants instead of one listener per element:
import { delegate } from 'ks-fwork';
// One-off, outside any component's lifecycle:
const unsubscribe = delegate(document.getElementById('app'), 'click', '[data-action="remove-todo"]', function (event) {
console.log('removing', this.dataset.id); // `this` is the matched element
});
// Or declaratively on a component, lifecycle-managed automatically:
defineComponent({
delegate: [
{
event: 'click',
selector: '[data-action="remove-todo"]',
handler(event, matchedEl) {
this.emit('remove', matchedEl.dataset.id); // `this` is the component
},
},
],
render() { /* children carry data-action/data-id, no listeners of their own */ },
});The declarative delegate: [...] option binds to the component's own root element on mount and unbinds on unmount — the same lifecycle-management pattern as stores: [...]. The standalone delegate() export is the primitive underneath it, usable anywhere you have a real DOM node, independent of any component.
It does not just reimplement addEventListener. The framework adds real behavior on top of the platform primitive: declarative preventDefault/stopPropagation configuration, automatic listener cleanup tied to component unmount, event listener diffing on re-render (a changed handler is swapped, not endlessly re-added), and the delegate() primitive built on real event bubbling. None of that exists if you only call addEventListener directly.
Forms and user input
Forms work like any other element — the submit event is just an event:
h('form', {
on: {
submit: {
handler: () => this.addTodo(),
preventDefault: true,
},
},
}, [
h('input', {
value: this.state.text,
on: { input: ({ target }) => this.updateState({ text: target.value }) },
}),
h('button', { type: 'submit' }, ['Add']),
]);preventDefault: true stops the browser's native form submission (which would otherwise navigate the page) so the app's own submit handler is the single source of truth for what happens. Native, browser-handled form behaviors — like <form method="dialog"> inside a <dialog> element — are unaffected, since the framework never wraps or intercepts the native Event object.
HTTP requests
The http export wraps fetch with sensible defaults for a typical JSON API — automatic Content-Type: application/json when a body is present, automatic JSON body serialization, automatic response parsing based on the response's actual Content-Type (falling back to plain text, and to null for an empty body like a 204 No Content), and a thrown Error for any non-2xx response:
import { http } from 'ks-fwork';
const users = await http.get('https://api.example.com/users');
await http.post('https://api.example.com/users', { name: 'Ada' });
await http.put(`https://api.example.com/users/${id}`, { name: 'Ada Lovelace' });
await http.delete(`https://api.example.com/users/${id}`);Because it throws on failure, the natural pattern is a try/catch around the call, updating component or store state either way:
async fetchTodos() {
this.updateState({ loading: true, error: null });
try {
const data = await http.get('/api/todos');
todoStore.setState({ todos: data.todos });
} catch (error) {
this.updateState({ error: error.message });
} finally {
this.updateState({ loading: false });
}
}This is how HTTP requests feed data sharing across the application: the fetched data is written into a Store, so every component subscribed to that store — on any page — picks up the new data automatically, the same way it would for a local mutation.
Best practices
Keep components focused on presentation; let a parent own the decision. A component like TodoItem should know how to render a todo and report that it was clicked — it shouldn't know what "completing a todo" means to the rest of the app. Emit generic events (complete, remove) with just enough payload (an id) for a parent to act on, rather than reaching into a store directly from a deeply nested, otherwise-reusable component. Reserve direct stores: [...] access in a "generic" component for reading cross-cutting state that isn't a business decision (feature flags, auth status, settings) — not for triggering domain mutations.
Always give keyed lists a stable key. Without a key, list diffing falls back to positional/type matching, which can cause unrelated DOM nodes (and their internal state, like focus or scroll position) to be reused for the wrong item after a reorder. Use a stable, unique identifier (todo.id, never an array index) as the key.
Prefer preventDefault/stopPropagation config over manually touching the event object, when the behavior is meant to be static or state-driven — it keeps render() declarative and makes the behavior visible directly in the vNode tree rather than buried inside a handler body.
Guard routes with beforeEnter rather than checking auth inside the page component. Redirecting before a protected page ever mounts avoids a flash of protected content and keeps the access-control logic in one place (the route table) instead of scattered across every protected page.
Don't let unrelated store subscriptions cause implicit ordering assumptions. Because stores: [...] triggers updateState({}) synchronously on a store change, a store update can cascade into further synchronous DOM operations (mounts and unmounts) within the same call stack. Handlers subscribed to a store should stay simple (usually just "re-render me") — put actual business logic inside the store's own methods, not inside a subscriber callback.
Performance
Decision: keyed reconciliation to avoid unnecessary DOM churn. List diffing (arraysDiffSequence) classifies each item into ADD, REMOVE, MOVE, or NOOP by comparing type, tag, and key (via areNodesEqual), instead of always tearing down and rebuilding a list wholesale on every render. A MOVE reuses the existing DOM node (insertBefore) instead of destroying and remounting it, and a NOOP skips straight to patching just the node's own attributes/children rather than replacing it.
Why it matters: destroying and remounting a DOM node loses anything the browser was tracking about it — input focus, scroll position, CSS transition state, and (in this framework specifically) it also tears down and recreates any component instance rooted there, discarding its local state. Keying avoids all of that for items that didn't actually change.
How to validate it: render a list of TodoItems, focus the checkbox on one item, then trigger a re-render that adds a new todo to the front of the list (shifting every existing item's array index by one). Without keys, the diffing algorithm would match by position and reuse each old DOM node for whatever now sits at that index — the focused checkbox would visually "jump" to a different todo, because the DOM node kept its focus but the data next to it changed. With key: todo.id (as the example app does), the algorithm instead detects that this is an ADD at index 0 followed by NOOPs for every existing item — the existing nodes, and the browser's focus tracking on them, stay attached to the correct todo.
Decision: skip re-rendering when incoming props/children haven't changed. Component#updateProps and #setExternalContent compare the incoming value against the current one with a deep-equality check (fast-deep-equal) before doing anything else, and return immediately — without calling render() or running the diff/patch cycle — when nothing actually changed.
Why it matters: a parent re-rendering doesn't necessarily mean every child's props changed. Without this guard, every re-render anywhere in a subtree would call render() and run a full diff on every descendant component, even ones whose inputs are byte-for-byte identical to last time.
How to validate it: add a temporary console.count('render:' + this.constructor.name) at the top of a component's render(), then trigger a state update in a sibling or parent that does not change this component's own props. With the equality guard in place, the counter for the untouched component does not increment on that update — only components whose actual props or children changed re-render.
