A lightweight VDOM library for JavaScript with a philosophy of minimalism.
npm install tyaff
import { h, Component, mount, refresh } from 'tyaff';
import { h, Component, mount } from 'tyaff';
const Hello = Component({
render() {
return h('h1', null, 'Hello, World!');
}
});
mount(Hello, document.getElementById('app'));
With a stateful component:
const Counter = Component({
count: 0,
increment() {
this.update({ count: this.count + 1 });
},
render() {
return h('div', null,
h('p', null, 'Counter: ' + this.count),
h('button', { onClick: this.increment }, '+')
);
}
});
mount(Counter, document.getElementById('app'));
Components are created via the factory Component(definition):
const MyComponent = Component({
// Initial state values
count: 0,
items: [],
// Custom methods (automatically bound to instance)
increment() { this.count++; this.update(); },
// Lifecycle methods
init() { /* initialization */ },
onMounted() { /* after DOM insertion */ },
onUpdated() { /* after update */ },
onUnmounted() { /* before removal */ },
// Required method
render() {
return h('div', null, 'Content');
}
});
Important:
thisAll key functions receive this.props as the first argument:
const Card = Component({
render({ title, text }) {
return h('div', { className: 'card' },
h('h2', null, title),
h('p', null, text)
);
}
});
// Usage
h(Card, { title: 'Title', text: 'Card text' })
Optional props() function allows transforming incoming data:
const Button = Component({
props(incoming) {
return {
label: incoming.label || 'Click me',
type: incoming.type || 'button',
disabled: Boolean(incoming.disabled)
};
},
render({ label, type, disabled }) {
return h('button', { type, disabled }, label);
}
});
Children are passed as children in props:
const Container = Component({
render({ title, children }) {
return h('div', { className: 'container' },
h('h1', null, title),
h('div', { className: 'content' }, children)
);
}
});
// Usage
h(Container, { title: 'My container' },
h('p', null, 'Paragraph 1'),
h('p', null, 'Paragraph 2')
)
All variables are mutable properties on the instance:
const Counter = Component({
count: 0,
items: [],
add() {
this.items.push('New element'); // direct mutation
this.count++;
this.update(); // notify about changes
}
});
// Forced update
this.update();
// With patch
this.update({ count: this.count + 1 });
// Returns Promise<boolean>
const changed = await this.update({ count: 10 });
if (changed) {
console.log('Data has changed');
}
After await update() the view is guaranteed to be up-to-date.
| Call | Returns |
|---|---|
update() |
true (forced render) |
update({}) |
false (empty patch) |
update(patch) with changes |
true |
update(patch) without changes |
false |
props(incoming) — props normalizationinit(props) — initialization statememo(props) — dependency computationrender(props) — VDOM creationonMounted() — after DOM insertionprops(incoming) — props updatememo(props) — dependency checkrender(props) — VDOM creation (only if memo allowed)onUpdated() — after update DOMAll key methods have access to the instance:
Component({
props(incoming) { /* this is available */ },
init(props) { /* this is available */ },
memo(props) { /* this is available */ },
render(props) { /* this is available */ }
});
const Timer = Component({
count: 0,
intervalId: null,
init() {
this.intervalId = setInterval(() => {
this.update({ count: this.count + 1 });
}, 1000);
},
onMounted() {
console.log('Component mounted');
},
onUnmounted() {
clearInterval(this.intervalId);
},
render() {
return h('div', null, 'Timer: ' + this.count);
}
});
memo() blocks render only for the current component. Children always go through their own update chain.
const ExpensiveList = Component({
memo(props) {
// render will only execute when items changes
return [props.items.length];
},
render({ items }) {
return h('ul', null,
items.map(item => h('li', { key: item.id }, item.text))
);
}
});
const Counter = Component({
count: 0,
memo(props) {
// Dependencies from props and state
return [props.value, this.count];
},
render(props) {
return h('div', null, props.value, this.count);
}
});
If a component reads context and uses memo, include context in dependencies:
const ThemedCard = Component({
memo(props) {
return [props.title, this.context('theme')];
},
render(props) {
return h('div', { className: this.context('theme') }, props.title);
}
});
memo() blocks render only for the current component. Children always go through their own props → memo → render, even if the parent is protected by memo().
Pull-based context without Provider/Consumer.
const ThemeProvider = Component({
theme: 'light',
context: {
theme() { return this.theme; },
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
this.update();
}
},
render() {
return h('div', null, this.props.children);
}
});
const ThemedButton = Component({
render() {
const theme = this.context('theme');
return h('button', { className: 'btn-' + theme }, 'Button');
}
});
// Mounting
mount(
h(ThemeProvider, null,
h(ThemedButton)
),
document.body
);
this.context(key) — get value from parentthis.contextSelf(key) — get value from self or parentA child component can override context:
const Page = Component({
context: {
lang() { return this.props.lang || this.context('lang'); }
},
render() {
return h('div', null, this.props.children);
}
});
this.refs — both a function and an object.
const InputFocus = Component({
onMounted() {
this.refs.input.focus();
},
render() {
return h('div', null,
h('input', { ref: this.refs('input'), type: 'text' }),
h('button', { onClick: () => this.refs.input.select() }, 'Select')
);
}
});
const Parent = Component({
onMounted() {
this.refs.child.someMethod();
},
render() {
return h(Child, { ref: this.refs('child') });
}
});
ref(node/instance) is calledref(null) is calledMounting в произвольный DOM-контейнер.
const Modal = Component({
render() {
if (!this.props.visible) return null;
return createPortal(
h('div', { className: 'modal' },
h('h2', null, this.props.title),
h('button', { onClick: this.props.onClose }, 'Close')
),
() => document.getElementById('modal-root')
);
}
});
// HTML
// <div id="app"></div>
// <div id="modal-root"></div>
If the container doesn’t exist yet, the portal waits:
createPortal(
children,
() => document.querySelector('.dynamic-container') // may return null
)
In React, a key is unique among siblings. In tyaff — among all elements in render.
This allows moving elements between different parents while preserving instance and state.
User key (element with key prop):
h(Component, { key: 'fio' }, ...) // identifier: #fio
Automatic key (element without key prop):
// List with keys
h('ul', null,
items.map(item =>
h('li', { key: item.id }, item.text)
)
)
// Moving between parents
h('div', null,
h(Component, { key: 'card' }, ...) // can be moved
)
Fragment with key allows moving groups of children:
h(Fragment, { key: 'group-a' },
h(Item, { key: 'i1' }),
h(Item, { key: 'i2' })
)
h('div', {
className: 'box', // → class="box"
htmlFor: 'input', // → for="input"
tabIndex: 0 // → tabindex="0"
})
h('button', {
onClick: (e) => console.log(e),
onChange: this.handleChange
})
h('div', {
style: { fontSize: '16px', backgroundColor: 'red' }
})
h('div', {
dangerouslySetInnerHTML: { __html: '<b>Bold</b>' }
})
h('svg', {
viewBox: '0 0 24 24',
width: 24,
height: 24
},
h('path', { d: 'M12 2L2 22h20L12 2z' })
)
Use DOM properties, not attributes:
const Form = Component({
formData: { name: '', email: '' },
handleChange(field, value) {
this.update({
formData: { ...this.formData, [field]: value }
});
},
render() {
return h('form', null,
h('input', {
type: 'text',
value: this.formData.name,
onChange: (e) => this.handleChange('name', e.target.value)
}),
h('input', {
type: 'email',
value: this.formData.email,
onChange: (e) => this.handleChange('email', e.target.value)
})
);
}
});
h('select', {
multiple: true,
value: this.selected, // массив
onChange: (e) => {
const values = Array.from(e.target.selectedOptions, opt => opt.value);
this.update({ selected: values });
}
},
options.map(opt => h('option', { value: opt }, opt))
)
Global update of all mounted trees:
const time = await refresh(); // time in milliseconds
console.log(`Render: ${time.toFixed(2)}ms`);
Components can read data from a global store:
// store.js
export const store = { count: 0, user: null };
// App.js
import { store } from './store.js';
import { refresh } from 'tyaff';
const Counter = Component({
render() {
return h('div', null, 'Count: ', store.count);
}
});
// Update
store.count = 55;
await refresh(); // all components will reread the store
Switching between development and production:
import { setDevMode } from 'tyaff';
if (process.env.NODE_ENV === 'production') {
setDevMode(false);
}
Development mode (default):
Production mode:
Important: In production, errors in components can break the entire update batch.
Используйте && внутри обёртки:
// ✅ Correct
render() {
return h('div', null,
this.show && h('span', null, 'content')
);
}
// ❌ Not recommended
render() {
return this.show ? h('div', null, 'text') : null;
}
Use virtualization (render only visible elements).
Getters из definition не копируются на instance. Используйте методы или вычисляйте в render().
export {
h, // VDOM node creation
Component, // Component factory
createPortal, // Portal creation
Fragment, // Fragment symbol
mount, // Mount to DOM
refresh, // Update всех компонентов
setDevMode // Switch dev/production mode
};
VDOM node creation.
Factory for creating components.
Universal function for mount, update, and unmount.
Global asynchronous update.
Switch development mode.