A compact web framework for minimalist developers.
Zero dependencies, no build step except for TypeScript compilation,
and a simple virtual DOM implementation that is easy to understand and use.
Autocompletion out of the box thanks to lib.dom.d.ts.
It brings a primitive building block to the table that gives flexibility in composition and makes refactoring easy. The use cases can be single page applications or isolated components with complex state.
Copy into a HTML file and open it in a browser to see the example in action.
<!DOCTYPE html><html><head><metacharset="utf-8"><title>Vode ESM Example</title></head><body><divid="app"></div><scripttype="module">import{app,context,createState,DIV,INPUT,LABEL,RECT,SVG,TEXT,PRE,CODE}from'https://unpkg.com/@ryupold/vode/dist/vode.min.mjs';// select a DOM element to be the root of the vode appconstappNode=document.getElementById('app');// create state with initial valuesconststate=createState({shape: {radius: 28,border: 8,hue: 265},text: 'Hello, Vode!',});// reach any state path through a lazily evaluated sub-contextconstshapeCtx=context(state).shape;consttextCtx=context(state).text;// components are ordinary functions that return lightweight vode arraysconstSlider=(valueCtx,label,max,hue)=>[LABEL,{style: {display: 'grid',gap: '5px'}},`${label}: ${valueCtx.get()}`,[INPUT,{type: 'range',min: 0, max,value: valueCtx.get(),style: {width: '100%',accentColor: `hsl(${hue} 80% 50%)`},// patch only this state path and trigger a renderoninput: (_,event)=>valueCtx.patch(Number(event.target.value)),}],];// all HTML attributes are supported ('style' and 'class' have additional convenience features)constTextInput=(valueCtx)=>[INPUT,{value: valueCtx.get(),maxlength: 18,placeholder: 'Type something',style: {padding: '9px 11px',border: '1px solid #cbd5e1',borderRadius: '8px'},oninput: (_,event)=>valueCtx.patch(event.target.value),}];// SVG uses exactly the same declarative structure as HTMLconstPreview=(s)=>[SVG,{xmlns: 'http://www.w3.org/2000/svg',viewBox: '0 0 320 220',style: {width: '100%'}},[RECT,{x: 25,y: 25,width: 270,height: 170,rx: s.shape.radius,fill: `hsl(${s.shape.hue} 75% 55%)`,stroke: `hsl(${s.shape.hue} 80% 25%)`,'stroke-width': s.shape.border,}],[TEXT,{x: 160,y: 110,fill: 'white','text-anchor': 'middle','dominant-baseline': 'middle','font-size': 24,'font-family': 'system-ui',},s.text],];// bind the root component, state and render functionapp(appNode,state,(s)=>[DIV,{id: 'app',style: {maxWidth: '720px',margin: '40px auto',padding: '24px',font: '15px system-ui',borderRadius: '20px',background: '#f8fafc',boxShadow: '0 16px 50px #0f172a22',}},[DIV,{style: {display: 'grid',gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',gap: '28px',alignItems: 'center'}},[DIV,{style: {display: 'grid',gap: '14px'}},// give each control only the state slice it ownsSlider(shapeCtx.radius,'Corner radius',80,s.shape.hue),Slider(shapeCtx.border,'Border width',20,s.shape.hue),Slider(shapeCtx.hue,'Color hue',360,s.shape.hue),TextInput(textCtx),],Preview(s),],// state is still plain data and can be rendered directly[PRE,[CODE,JSON.stringify(s,null,2)]]]);</script></body></html>Binds the library to the global V variable.
<!DOCTYPE html><html><head><metacharset="utf-8"><scriptsrc="https://unpkg.com/@ryupold/vode/dist/vode.es5.min.js"></script><title>Vode ES5 (IIFE) Script Example</title></head><body><divid="app"></div><script>varappNode=document.getElementById('app');varstate=V.createState({shape: {radius: 28,border: 8,hue: 265},text: 'Hello, Vode!'});varctx=V.context(state);functionSlider(valueCtx,label,max,hue){return["label",{style: {display: 'grid',gap: '5px'}},label+': '+valueCtx.get(),["input",{type: 'range',min: 0,max: max,value: valueCtx.get(),style: {width: '100%',accentColor: 'hsl('+hue+' 80% 50%)'},oninput: function(_,event){valueCtx.patch(Number(event.target.value));}}]];}functionTextInput(valueCtx){return["input",{value: valueCtx.get(),maxlength: 18,placeholder: 'Type something',style: {padding: '9px 11px',border: '1px solid #cbd5e1',borderRadius: '8px'},oninput: function(_,event){valueCtx.patch(event.target.value);}}];}functionPreview(s){return["svg",{xmlns: 'http://www.w3.org/2000/svg',viewBox: '0 0 320 220',style: {width: '100%'}},["rect",{x: 25,y: 25,width: 270,height: 170,rx: s.shape.radius,fill: 'hsl('+s.shape.hue+' 75% 55%)',stroke: 'hsl('+s.shape.hue+' 80% 25%)','stroke-width': s.shape.border}],["text",{x: 160,y: 110,fill: 'white','text-anchor': 'middle','dominant-baseline': 'middle','font-size': 24,'font-family': 'system-ui'},s.text]];}V.app(appNode,state,function(s){return["div",{id: 'app',style: {maxWidth: '720px',margin: '40px auto',padding: '24px',font: '15px system-ui',borderRadius: '20px',background: '#f8fafc',boxShadow: '0 16px 50px #0f172a22'}},["div",{style: {display: 'grid',gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',gap: '28px',alignItems: 'center'}},["div",{style: {display: 'grid',gap: '14px'}},Slider(ctx.shape.radius,'Corner radius',80,s.shape.hue),Slider(ctx.shape.border,'Border width',20,s.shape.hue),Slider(ctx.shape.hue,'Color hue',360,s.shape.hue),TextInput(ctx.text)],Preview(s)],["pre",["code",JSON.stringify(s,null,2)]]];});</script></body></html>Create a project and install Vode:
mkdir vode-example
cd vode-example
npm init --init-type=module -y
tsc --init
npm install @ryupold/vode --saveThe browser cannot resolve a bare import such as @ryupold/vode on its own. This example uses an import map to resolve it directly from node_modules, so it does not need a bundler or any additional npm packages.
index.html
<!DOCTYPE html><html><head><metacharset="utf-8"><title>Vode NPM Example</title><scripttype="importmap">{"imports": {"@ryupold/vode": "./node_modules/@ryupold/vode/dist/vode.min.mjs"}}</script><scripttype="module" src="main.js"></script></head><body><divid="app"></div></body></html>main.ts
import{app,context,createState,typeSubContext,typeVode,DIV,INPUT,LABEL,RECT,SVG,TEXT,PRE,CODE,}from'@ryupold/vode';conststate=createState({shape: {radius: 28,border: 8,hue: 265},text: 'Hello, Vode!',});typeState=typeofstate;constctx=context(state);constappNode=document.getElementById('app')!;constSlider=(valueCtx: SubContext<number>,label: string,max: number,hue: number)=>[LABEL,{style: {display: 'grid',gap: '5px'}},`${label}: ${valueCtx.get()}`,[INPUT,{type: 'range',min: 0, max,value: valueCtx.get(),style: {width: '100%',accentColor: `hsl(${hue} 80% 50%)`},oninput: (_: unknown,event: Event)=>valueCtx.patch(Number((event.targetasHTMLInputElement).value)),}],];constTextInput=(valueCtx: SubContext<string>)=>[INPUT,{value: valueCtx.get(),maxlength: 18,placeholder: 'Type something',style: {padding: '9px 11px',border: '1px solid #cbd5e1',borderRadius: '8px'},oninput: (_: unknown,event: Event)=>valueCtx.patch((event.targetasHTMLInputElement).value),}];constPreview=(s: State)=>[SVG,{xmlns: 'http://www.w3.org/2000/svg',viewBox: '0 0 320 220',style: {width: '100%'}},[RECT,{x: 25,y: 25,width: 270,height: 170,rx: s.shape.radius,fill: `hsl(${s.shape.hue} 75% 55%)`,stroke: `hsl(${s.shape.hue} 80% 25%)`,'stroke-width': s.shape.border,}],[TEXT,{x: 160,y: 110,fill: 'white','text-anchor': 'middle','dominant-baseline': 'middle','font-size': 24,'font-family': 'system-ui',},s.text],];app(appNode,state,(s)=><Vode>[DIV,{id: 'app',style: {maxWidth: '720px',margin: '40px auto',padding: '24px',font: '15px system-ui',borderRadius: '20px',background: '#f8fafc',boxShadow: '0 16px 50px #0f172a22',}},[DIV,{style: {display: 'grid',gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',gap: '28px',alignItems: 'center'}},[DIV,{style: {display: 'grid',gap: '14px'}},Slider(ctx.shape.radius,'Corner radius',80,s.shape.hue),Slider(ctx.shape.border,'Border width',20,s.shape.hue),Slider(ctx.shape.hue,'Color hue',360,s.shape.hue),TextInput(ctx.text),],Preview(s),],[PRE,[CODE,JSON.stringify(s,null,2)]]]);Run
tsc -bto output the main.js.
Now you can serve the directory with a static file server (e.g. npx http-server).
In an application that already uses a bundler, omit the import map and keep the same bare import.
Let's describe UI as data structures that map 1:1 to DOM elements.
A vode is a representation of a virtual DOM node, which is a tree structure of HTML elements. It is written as a tuple:
[TAG, PROPS?, CHILDREN...]
As you can see, it is a simple array with the first element being the tag name, the second element being an optional properties object, and the rest being child vodes.
They are lightweight structures to describe what the DOM should look like.
Imagine this HTML:
<divclass="card"><divclass="card-image"><figureclass="image is-4by3"><imgsrc="placeholders/1280x960.png"
alt="Placeholder image"
/></figure></div><divclass="card-content"><divclass="media"><divclass="media-left"><figureclass="image is-48x48"><imgsrc="placeholders/96x96.png"
alt="Placeholder image"
/></figure></div><divclass="media-content"><pclass="title is-4">John Smith</p><pclass="subtitle is-6">@johnsmith</p></div></div><divclass="content">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
<ahref="?post=vode">vode</a>. <ahref="#">#css</a><ahref="#">#responsive</a><br/><timedatetime="2025-09-24">10:09 PM - 24 Sep 2025</time></div></div></div>expressed as vode structure it would look like this:
[DIV,{class: 'card'},[DIV,{class: 'card-image'},[FIGURE,{class: 'image is-4by3'},[IMG,{src: 'placeholders/1280x960.png',alt: 'Placeholder image'}]]],[DIV,{class: 'card-content'},[DIV,{class: 'media'},[DIV,{class: 'media-left'},[FIGURE,{class: 'image is-48x48'},[IMG,{src: 'placeholders/96x96.png',alt: 'Placeholder image'}]]],[DIV,{class: 'media-content'},[P,{class: 'title is-4'},'John Smith'],[P,{class: 'subtitle is-6'},'@johnsmith']]],[DIV,{class: 'content'},'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',[A,{href: '?post=vode'},'vode'],'. ',[A,{href: '#'},'#css'],[A,{href: '#'},'#responsive'],[BR],[TIME,{datetime: '2025-09-24'},'10:09 PM - 24 Sep 2025']]]]Viewed in isolation, it does not provide an obvious benefit (apart from looking better IMHO), but as a function of state, it can become very useful to express conditional UI this way.
app is a function that takes an HTML node, a state object, and a render function (Component<State>).
constcontainerNode=document.getElementById('ANY-ELEMENT');conststate={counter: 0,pointing: false,loading: false,title: '',body: '',};constpatch=app(containerNode,state,(s)=>[DIV,[P,{style: {color: 'red'}},`${s.counter}`],[BUTTON,{onclick: ()=>({counter: s.counter+1})},'Click me'],]);It will analyze the current structure of the given ContainerNode and adjust its structure in the first render.
When render-patches are applied to the patch function or via yield/return of events,
the ContainerNode is updated to match the vode structure 1:1.
app()infers the state type from the second argument, so you don't need explicit generics or parameter types in thedomfunction. If you prefer, you can still write them explicitly:typeState=typeofstate;app<State>(appNode,state,(s: State)=> ...);
To release resources associated with the vode app instance, you can call the defuse function on the ContainerNode that was passed to app.
import{app,defuse}from'@ryupold/vode';constcontainerNode=document.getElementById('ANY-ELEMENT');conststate={/* ... */};app(containerNode,state,s=>/* ... */);//... later ...// when you want to clean up the vode app instancedefuse(containerNode);The DOM elements created by the vode app will remain in the ContainerNode, but all event listeners and references to the state object will be removed, allowing for proper garbage collection.
typeComponent<S>=(s: S)=>ChildVode<S>;A Component<State> is a function that takes a state object
and returns a ChildVode (Vode<State> or string or null).
It is used to render the UI based on the current state.
A new vode structure must be created on each render, otherwise it would be skipped which could lead to unexpected results. If you seek to improve render performance, have a look at the memo function.
// A full vode has a tag, properties, and children. props and children are optional.constCompFoo=(s)=>[SPAN,{class: "foo"},s.isAuthenticated ? "foo" : "bar"];constCompBar=(s)=>[DIV,{class: "container"},// a child vode can be a string, which results in a text node[H1,"Hello World"],// a vode can also be a self-closing tag[HR],// conditional renderings.isAuthenticated
? [STRONG,`and also hello ${s.user}`]
: [FORM,[INPUT,{type: "email",name: "email"}],[INPUT,{type: "password",name: "pw"}],[INPUT,{type: "submit"}],],// a child-vode of false, undefined or null is not rendered!s.isAuthenticated&&[HR],// style object maps directly to the HTML style attribute[P,{style: {color: "red",fontWeight: "bold"}},"This is a paragraph."],[P,{style: "color: red; font-weight: bold;"},"This is also a paragraph."],// class property has multiple forms[UL,[LI,{class: "class1 class2"},"as string"],[LI,{class: ["class1","class2"]},"as array"],[LI,class: {class1: true,class2: false}},"as Record<string, boolean>"],],// events get the state object as first argument// and the HTML event object as second argument[BUTTON,{// all on* events accept `Patch<State>`onclick: (s,evt)=>{// objects returned by events are patched automaticallyreturn{counter: s.counter+1};},// you can set the patch object directly for eventsonmouseenter: {pointing: true},onmouseleave: {pointing: false},// a patch can be an async functiononmouseup: async(s,evt)=>{s.patch({loading: true});constresult=awaitapiCall();return{title: result.data.title,loading: false};},// you can also use a generator function that yields patchesonmousedown: asyncfunction*(s,evt){yield{loading: true};constresult=awaitapiCall();yield{body: result.data.body,};return{loading: false};},// events can be attached conditionallyondblclick: s.counter>20&&((s,evt)=>{return{counter: s.counter*2};}),class: {bar: s.pointing}},"Click me!"],// components can be used as child-vodes, they are called lazily on renderCompFoo,// or this wayCompFoo(s),];The state object you pass to app can be updated directly or via patch.
During the call to app, the state object is bound to the vode app instance
and becomes a singleton from its perspective.
A patch function is also added to the state object; it is the same function that is returned by app.
A re-render happens when a patch object is supplied to the patch function or via event.
When an object is passed to patch, its properties are recursively deep merged onto the state object.
Use createState() if you need to queue patches before app() initialization.
consts={counter: 0,pointing: false,loading: false,title: 'foo',body: '',};app(appNode,s,s=>AppView(s));// after calling app(), the state object is bound to the appNode// update state directly as it is a singleton (silent patch, no render)s.title='Hello World';// render patchs.patch({});// render patch with a change that is applied to the states.patch({title: 'bar'});// patch with a function that receives the states.patch((s)=>({body: s.body+' baz'}));// patch with an async function that receives the states.patch(async(s)=>{s.loading=true;// sometimes it is easier to combine a silent patchs.patch({});// with an empty render patchconstresult=awaitapiCall();return{title: result.title,body: result.body,loading: false};});// can be awaited to wait for execution// patch with an async generator function that yields patchess.patch(asyncfunction*(s){yield{loading: true};constresult=awaitapiCall();yield{title: result.title,body: result.body};return{loading: false};});// can be awaited to wait for execution// ignored, also: undefined, number, string, boolean, symbol, voids.patch(null);// setting a property in a patch to undefined deletes it from the state objects.patch({pointing: undefined});// ❌ it is discouraged to patch inside the render step 💩constComponentEwww=(s)=>{if(!s.isLoading)s.patch(()=>startLoading());return[DIV,s.isLoading ? [PROGRESS] : s.title];}// ✨ experimental view transitions support ✨// patch with a render via view transitions.patch([{},(s)=>{/*...*/}]);// all given patches will be part of a view transition// an empty array tells vode to skip the current view transition// and set the queued animated patches until now as current state with a sync patchs.patch([]);// skip current view transition and start this view transition insteads.patch([[],{loading: true}]);Symbols are ignored
The state can contain
symbolkeys and you can get/set them any time, but they will be ignored when contained in a render patch. So a render patch like{ [Symbol('foo')]: 'some value' }is equivalent to patching{}.
To optimize performance, you can use memo(depsArray, Component) to cache the result of a component function.
If the array of dependencies does not change (shallow compare), the component function is not called again,
indicating for the render to skip this node and all its children.
This is useful when the creation of the vode is expensive or the rendering of it
takes a significant amount of time.
conststate=createState({title: "hello",body: "world"});typeState=typeofstate;constCompMemoList: Component<State>=(s)=>[DIV,{class: "container"},[H1,"Hello World"],[BR],[P,"This is a paragraph."],// expensive component to rendermemo(// this array is used to determine when to re-render the component; it is shallow-compared against the previous render's array[s.title,s.body],// this is the component function that will be// called only when the array changes(s)=>{constlist=<Vode>[UL];for(leti=0;i<10000;i++){list.push([LI,`Item ${i}`]);}returnlist;},)];app(container,state,(s)=>[DIV,CompMemoList,]);Passing an empty dependency array means the component is only rendered once and then ignored.
Vode normally reconciles children by position:
on every render the DOM node at index i is patched to match the new child at index i.
For lists this is usually fine, but when entries are reordered, inserted or removed in the middle,
and the vode+elements identity matter because you need to reference the DOM nodes to preserve state
(e.g. scroll position, input values, running animations & transitions),
the keyed() helper function can be used to match children by a stable key instead of their position.
import{keyed}from'@ryupold/vode';conststate=createState({todos: [{id: "1",text: "write docs"},{id: "2",text: "peer review"},{id: "3",text: "publish"},]});app(container,state,(s)=>[DIV,keyed([UL,{class: "todos"},
...s.todos.map((t)=>[LI,{key: t.id},t.text]),]),]);During rendering, children that kept their key are reused and patched in place (keeping their exact DOM node),
new keys are created, removed keys are unmounted (onUnmount fires),
and reordered nodes are physically moved with the minimum number of DOM operations.
Keys are only unique within the first child level of the vode passed to the keyed() call,
so it is no problem to reuse the same keys in other or even nested keyed calls.
consequences:
- keys must be unique strings within one
keyed()call - the child carrying the
keymust be a plain[tag, { key, ... }, ...]vode, no string or component. - falsy children from conditional rendering (
false,null,undefined) are skipped, socond && [LI, { key: "x" }, "..."]works
keyed() and memo() complement each other: keyed preserves the DOM identity of a row across reorders, memo skips re-rendering unchanged content. Because keyed aligns the previous render's children by key, a memo inside a row is always compared against the same logical row, no matter where it moved in the list:
app(container,state,(s)=>[DIV,keyed([UL,
...s.todos.map((t)=>[LI,{key: t.id},// re-renders only when this todo's text actually changed,// even after the list was reorderedmemo([t.text],()=>[SPAN,{class: "todo"},t.text]),]),]),]);Note that due to the way keyed works, memo cannot be directly nested inside
a keyed call but must be wrapped inside at least one vode.
You can catch errors during rendering by providing a catch property in the vode props.
constCompWithError: ChildVode=()=>[DIV,{catch: (s: Patchable,err: Error)=>[SPAN,{style: {color: 'red'}},`An error occurred: ${err.message}`],},[P,"The error below is intentional for testing error boundaries:"],[DIV,{// catch: [SPAN, { style: { color: 'red' }}, `An error occurred!`], // uncomment to catch child error directly hereonMount: (s: Patchable,ele: HTMLElement)=>{thrownewError("Test error boundary in post view....");}}],];If the catch property is a function, it will be called with the current state and the error as arguments,
and should return a valid child-vode to render instead.
If it is a vode, it will be rendered directly.
If no catch property is provided, the error will propagate to the nearest ancestor
that has a catch property defined, or to the top-level app if none is found.
Try to keep the catch blocks as specific as possible to avoid masking other errors.
Or just don't make errors happen in the first place :)
The library provides some helper functions for common tasks.
import{tag,props,children,mergeClass,mergeStyle,mergeProps,hydrate,vode}from'@ryupold/vode';// Merge class props intelligently (additive)mergeClass('foo',['baz','bar']);// -> 'foo baz bar'mergeClass(['foo'],{bar: true,baz: false});// -> 'foo bar'mergeClass({zig: true,zag: false},'foo',['baz','bar']);// -> 'zig foo baz bar'// Merge style properties intelligently. Later values override earlier ones.mergeStyle({color: 'red'},'font-weight: bold;');// -> 'color: red; font-weight: bold;'mergeStyle('color: white; background-color: blue;',{marginTop: '10px',color: 'green'});// -> 'color: green; background-color: blue; margin-top: 10px;'// Merge props objects intelligently (class and style props are merged with the helper functions above, other props are overwritten from left to right)mergeProps({title: 'Hello',src: 'foo.png',class: 'foo',style: {color: 'red'}},{id: 'my-element',src: 'bar.png',class: ['bar','baz'],style: 'font-weight: bold;'},);/* -> { title: 'Hello', id: 'my-element', src: 'bar.png', class: 'foo bar baz', style: 'color: red; font-weight: bold;'} */// create a vodeconstmyVode: Vode=[DIV,{class: 'foo'},[SPAN,'hello'],[STRONG,'world']];constalsoMyVode1: Vode=vode(DIV,{class: 'foo'},[SPAN,'hello'],[STRONG,'world']);constalsoMyVode2: Vode=vode([DIV,{class: 'foo'},[SPAN,'hello'],[STRONG,'world']]);// access parts of a vodetag(myVode);// 'div'props(myVode);// { class: 'foo' }children(myVode);// [[SPAN, 'hello'], [STRONG, 'world']]// get existing DOM element as a vode (can be helpful for analyzing/debugging)constasVode=hydrate(document.getElementById('my-element'));Additionally to the standard HTML attributes, you can define 2 special event attributes:
onMount(State, Element) and onUnmount(State, Element) in the vode props.
onMount runs after an element is attached, onUnmount just before it is detached.
They receive the State as the first argument and the DOM element as the second argument.
constcontainer=document.getElementById('app')!;conststate=createState({startTime: 0,inputReady: false,showInput: true,showTimer: true});constpatch=app(container,state,(s)=>[DIV,s.showInput&&[INPUT,{type: 'text',placeholder: 'Auto-focused on mount',onMount: (s: typeofstate,ele: HTMLElement)=>{console.log('Input mounted');(eleasHTMLInputElement).focus();return{inputReady: true};},onUnmount: (s: typeofstate,ele: HTMLElement)=>{console.log('Input removed');return{inputReady: false};}}],s.showTimer&&[P,{onMount: (s: typeofstate,ele: HTMLElement)=>{console.log('Timer started');s.patch({startTime: Date.now()});},onUnmount: (s: typeofstate,ele: HTMLElement)=>{console.log('Timer stopped after',Date.now()-s.startTime,'ms');}},'Mount/unmount lifecycle demo']]);// OUTPUT:// 1. Input mounted// 2. Timer startedpatch({showInput: false});// 3. Input removedpatch({showTimer: false});// 4. Timer stopped after XY msLike the other events (onclick, onmouseenter, etc.), these can also be attached conditionally and will be added or removed on the fly during rendering. Returning a patch object from these events will patch the same way as with events.
Note that in certain situations onMount/onUnmount will not be called. For example consider this transition:
constCompA: Component=()=>[ARTICLE,[DIV,{onMount: ()=>console.log("mount A"),onUnmount: ()=>console.log("unmount A")},"Component A"]];constCompB: Component=()=>[ARTICLE,[DIV,{onMount: ()=>console.log("mount B"),onUnmount: ()=>console.log("unmount B")},"Component B"]];conststate=createState({showB: false});app(container,state,s=>[DIV,s.showB ? CompB : CompA,]);state.patch({showB: true});// Output:// > "mount A"onMount of B and onUnmount of A are not called because DOM does not require element creation or removal (same TAGs)
When app() hydrates pre-existing DOM (e.g. server-rendered HTML),
the matching elements take this same A->A path, so their onMount does not fire automatically.
The hooks are still reflected onto the DOM node though, so you can invoke them yourself after hydration:
constnode=document.getElementById('my-hydrated-element')!;node.onMount(node);// runs your onMount(state, node) and patches its return value// node.onUnmount(node); // likewise for onUnmountSVG and MathML elements are supported but need the namespace defined in properties.
import{SVG,CIRCLE}from'@ryupold/vode';constCompSVG=(s)=>[SVG,{xmlns: 'http://www.w3.org/2000/svg',width: 100,height: 100},[CIRCLE,{cx: 50,cy: 50,r: 40,stroke: 'green','stroke-width': 4,fill: 'yellow'}]];import{MATH,MSUP,MI,MN}from'@ryupold/vode';constCompMathML=(s)=>[MATH,{xmlns: 'http://www.w3.org/1998/Math/MathML'},[MSUP,[MI,'x'],[MN,'2']]];The state context utilities can help create shareable, type-safe components. These do not need to know the 'full' state of the app, but only the part they are interested in. This can be especially useful for differently deep nested components that need access to the same part of the state.
import{Vode,app,context,createState,ProxySubContext,SubContext,DIV,FORM,H1,LABEL,OPTION,SELECT}from"@ryupold/vode";typeSettings={theme: string,lang: string};typeStateType={user: {profile: {settings: Settings}}};conststate=createState<StateType>({user: {profile: {settings: {theme: 'dark',lang: 'es'}}}});// Create a context for the nested settingsconstsettingsCtx=context(state).user.profile.settings;constelement=document.getElementById('app')!;app(element,state,(s)=>[DIV,[H1,"Settings"],SettingsForm(settingsCtx),SettingsFormWithSelection(settingsCtx),]);/** simple settings form */functionSettingsForm(settingsCtx: SubContext<Settings>){constsettings=settingsCtx.get();// { theme: 'dark', lang: 'es' }return<Vode>[FORM,[DIV,[LABEL,{for: 'theme'},'theme: ',settings.theme],[SELECT,{id: 'theme',onchange: (_: unknown,e: Event)=>settingsCtx.patch({theme: (<HTMLSelectElement>e.target).value}),value: settings.theme,},[OPTION,{value: 'light',selected: settings.theme==='light'},'light'],[OPTION,{value: 'dark',selected: settings.theme==='dark'},'dark'],],],[DIV,[LABEL,{for: 'language'},'language: ',settings.lang],[SELECT,{id: 'language',onchange: (_: unknown,e: Event)=>settingsCtx.patch({lang: (<HTMLSelectElement>e.target).value}),value: settings.lang,},[OPTION,{value: 'en',selected: settings.lang==='en'},'en'],[OPTION,{value: 'de',selected: settings.lang==='de'},'de'],[OPTION,{value: 'es',selected: settings.lang==='es'},'es'],[OPTION,{value: 'fr',selected: settings.lang==='fr'},'fr'],],],];}/** the same, but further broken up into smaller sub-components * note the usage of ProxySubContext to avoid having * to pass the entire settings object around*/functionSettingsFormWithSelection(settingsCtx: ProxySubContext<Settings>){return<Vode>[FORM,Selection(settingsCtx.theme,'theme',[{value: 'light',label: 'light'},{value: 'dark',label: 'dark'},]),Selection(settingsCtx.lang,'language',[{value: 'en',label: 'en'},{value: 'de',label: 'de'},{value: 'es',label: 'es'},{value: 'fr',label: 'fr'},]),];}functionSelection(valueCtx: SubContext<string>,name: string,options: {value: string,label: string}[]){constvalue=valueCtx.get();return<Vode>[DIV,[LABEL,{for: name},name+': ',value],[SELECT,{id: name,onchange: (_: unknown,e: Event)=>valueCtx.patch((<HTMLSelectElement>e.target).value),value: value,},
...options.map((o)=>[OPTION,{value: o.value,selected: value===o.value},o.label]),],];}When you have deeply nested state, context gives you a way to access and patch that slice without manually writing the full path every time. The context itself is always lazily evaluated, so you don't have to worry about intermediate object references changing.
A state context has 3 functions:
- get(): returns the sub-state targeted by this context
- put(value): assign the given value to the sub-state place (see silent patch). Use this if you want to ensure the object reference of value is preserved
- patch(value, animated): patch the given value to the sub-state by constructing the necessary nested structure (see render patch)
You can have multiple isolated vode app instances on a page, each with its own state and render function.
The returned patch function from app can be used to synchronize the state between them.
See test/tests-examples.ts for more advanced examples of the features described here.
The library has experimental support for the View Transitions API.
You can pass an array of patches to the patch function where each patch will be applied with the next available view transition.
Patching an empty array [] will skip the current view transition and set the queued animated patches until now as current state with a sync patch.
This results in two patch paths: sync patches merge into the state and render right away, while animated patches are queued and merged into the state just before their transition runs. A few consequences follow from this:
- Events and effects read the current sync state. Queued animated changes are not visible to code running before the transition.
- A sync patch does not see pending animated patches. When the transition runs, the queued values are merged on top.
- While the document is hidden, animated patches are applied as a sync patch.
Keep in mind that view transitions are not supported in all browsers yet and only one active transition can happen at a time. This feature may change significantly in the future, so do not rely on it heavily.
Scheduling behavior can be overridden with containerNode[$VODE].asyncRenderer.
// disable view transitions for a specific vode-app// (animated patches become sync patches for this app only)containerNode[$VODE].asyncRenderer=null;There are some metrics available on the appNode. They are updated on each render.
app(appNode,state,(s)=> ...);console.log(state[$STATS]);// orconsole.log(appNode[$VODE].stats);{// number of patches applied to the state overallpatchCount: 100,// number of render-patches (objects) overallsyncRenderPatchCount: 55,// number of view transition render-patches (arrays) overallasyncRenderPatchCount: 3,// number of sync "normal" renders performed overallsyncRenderCount: 43,// number of async renders performed overallasyncRenderCount: 2,// time the last render took in millisecondslastSyncRenderTime: 2,// time the last view transition took in millisecondslastAsyncRenderTime: 21,// number of active async running effects (function based patches)liveEffectCount: 0,}The library is optimized for small to medium sized applications. In my own tests it could easily
handle sites with tens of thousands of elements. Smart usage of memo can help to optimize
performance further. You can find a comparison of the performance with other libraries
here.
This being said, the library does not focus on performance. It is designed to feel nice while coding, by providing a primitive that is simple to bend & form. I want the mental model to be easy to grasp and the API surface to be small so that a developer can focus on building a web application instead of learning the framework and get to a flow state as quickly as possible.
Vode uses the same broad idea as other declarative UI libraries: describe the DOM you want for the current state, then let the framework update the existing DOM. The main difference is that Vode expresses this with ordinary functions, objects, and arrays, without JSX, templates, decorators, single-file components, or a hook runtime.
- React: A Vode component resembles a function component, but returns a vode such as
[DIV, ...children]instead of JSX. Props are ordinary function arguments,state.patch(...)fills the role of a state setter, andmemo(...)can skip an expensive unchanged subtree. There are no hook ordering rules. - Angular: A Vode component combines the role of a small component class and its template into one function. Function arguments replace inputs, event properties replace template event bindings, and an explicit state patch triggers rendering. Vode has no modules, decorators, dependency-injection container, template compiler, or automatic change-detection pass.
- Vue: A Vode component is comparable to a render function without a single-file component wrapper. Function arguments replace props, conditional arrays replace directives such as
v-if,keyed(...)provides keyed list reconciliation, and patches replace tracked reactive mutations. Vode does not track property access or require refs, watchers, or composables. - Plain JavaScript: Tags, properties, events, and DOM elements keep their normal browser meanings. Instead of coordinating
createElement,textContent,addEventListener, and cleanup manually, return a vode and letapp(...)reconcile its managed DOM subtree. You can still call browser APIs directly when needed.
| Task | Vode approach |
|---|---|
| Define a component | Write an ordinary function that receives state or other arguments and returns a child vode. |
| Pass props or inputs | Pass normal function arguments; there is no separate component-instance or props system. |
| Update state and render | Call state.patch({ key: value }), or return a patch from an event or effect. Direct mutation is silent until a render patch is applied. |
| Handle an event | Assign a patch object or a function returning a patch to an event property such as onclick. |
| Render a list | Use normal array operations such as map. Add string keys and wrap the container in keyed(...) when DOM identity must survive insertion, removal, or reordering. |
| Run asynchronous work | Pass or return functions, promises, or generators as effects; they can produce further patches. |
| Handle mount and cleanup | Use onMount and onUnmount when work depends on the actual DOM element or needs explicit cleanup. |
| Avoid unnecessary work | Use memo(...) when constructing or reconciling a subtree is measurably expensive. |
The simplicity of Hyperapp demonstrated that powerful frameworks don't require complexity, which inspired this library's design philosophy.
I'm not planning to add more features, just keeping it simple and easy (and hopefully bug free).
But if you find bugs or have suggestions, feel free to open an issue or a pull request.
