Reactive template library with direct DOM updates and Proxy-based reactivity.
GTPL is a TypeScript library for reactive template systems built around Direct DOM and Proxy. Inspired by Vue, Angular AOT, and JSX, it delivers efficient reactive UI updates in a compact 11kB JavaScript package.
GTPL provides:
- A small runtime for reactive rendering.
- Template compilation in two modes: JIT (runtime) and AOT (precompiled).
- Built-in directives for conditional rendering, loops, switching, styles, and dynamic attributes.
Interactive examples: https://garag-lib.github.io/GTPL/
Interactive benchmarks: https://garag-lib.github.io/GTPL/benchmarks.html Compare GTPL with React, Svelte, Vue, Alpine, Angular and direct DOM using real browser measurements. See benchmark methodology and build instructions.
npm install @mpeliz/gtplimport gtpl from '@mpeliz/gtpl';
const { GTpl, jit: { GCode, GCompile } } = gtpl;
const template = `
<div>
<h2>Counter: {{ count }}</h2>
<button onclick="{{ this.count = this.count + 1 }}">+1</button>
<button onclick="{{ this.count = 0 }}">Reset</button>
</div>
`;
const root = { count: 0 };
const aot = GCode(template);
const generator = GCompile(aot);
const app = new GTpl({ root, generator });
app.addTo(document.getElementById('app'));Mount into a DocumentFragment and await the initial render before replacing
the container's children. Supply the original template and the same initial
data used by the server:
const container = document.getElementById('app');
const root = JSON.parse(document.getElementById('initial-data').textContent);
const fragment = document.createDocumentFragment();
let app;
try {
app = new GTpl({ root, generator });
app.addTo(fragment);
await app.ready();
container.replaceChildren(fragment);
} catch (error) {
app?.destroy();
console.error(error);
}The container stays in place, and the generated nodes retain their listeners
and bindings when moved. The mounting step must precede ready() so structural
directives can insert and remove nodes. The same sequence works with JIT and AOT
generators.
ready() returns a cached Promise<void> for the initial render, including
asynchronous bindings and work started by generated children (g-for, g-if,
g-switch, and g-is). It rejects on an initial binding/render error or if the
instance is destroyed before initialization finishes. It does not trigger a
refresh or wait for subsequent reactive updates. With refresh: false, start
the initial refresh explicitly before calling ready().
| Mode | When to use | Flow |
|---|---|---|
| JIT | Dynamic templates generated at runtime | template -> GCode -> GCompile -> GTpl |
| AOT | Stable templates compiled during build | precompiled code -> GCompile -> GTpl |
AOT is recommended for production when templates are known in advance.
| Syntax | Meaning |
|---|---|
{{ foo }} |
Read variable |
{{ 'hello' }} |
Constant |
{{ foo:format }} |
Pipe/chained function |
{{ return foo + 1 }} |
Formula expression |
onclick="{{ this.count++ }}" |
Event expression |
[value]="{{ foo }}" |
Two-way bind property |
| Directive | Purpose |
|---|---|
g-if |
Render when truthy |
g-notif |
Render when falsy |
g-switch / g-case |
Switch/case rendering |
g-for="arr;index;value" |
List rendering |
g-attr |
Dynamic attributes from object |
g-style |
Dynamic inline styles |
g-inner |
Set innerHTML |
g-tpl |
Reuse named templates |
g-bind / g-binds |
Element/collection references |
import gtpl from '@mpeliz/gtpl';
const { GTpl, GregisterDirective, jit: { GCode, GCompile } } = gtpl;Main pieces:
GCode(html): parses template HTML into generated code string.GCodeOptimized(html): opt-in imperative backend; creates ordinary DOM subtrees directly and keeps lazy generators at structural directives.GCompile(code): compiles generated code into a render function.new GTpl({ root, generator }): creates reactive instance.instance.addTo(node): mounts rendered nodes.instance.watch(path, cb): subscribes to reactive path changes.instance.refresh(): triggers recomputation.instance.ready(): waits for initial rendering; mount before awaiting.instance.destroy(): removes listeners, bindings, and rendered nodes.GregisterDirective(name, class): register custom directive.
Use the imperative backend with the existing compile and mount APIs:
const { GTpl, jit: { GCodeOptimized, GCompile } } = gtpl;
const generator = GCompile(GCodeOptimized('<div><span>{{name}}</span></div>'));
const app = new GTpl({ root: { name: 'GTPL' }, generator });
app.addTo(document.body);
await app.ready();GCode remains the default/reference backend. Both compilers use the same parser
and structural directive handling. The imperative backend emits one function for
ordinary descendants, creates fresh DOM and binding records for each invocation,
and retains deferred generators for g-if, g-for, g-switch and g-is.
To reduce emitted source size, enable GCodeOptimized(template, { methodAliases: true }).
Repeated method names then use local property-key aliases, for example
const $a = "appendChild"; parent[$a](child). This preserves each method's
receiver and avoids adding wrapper functions. This option defaults to false:
it reduces large templates' bytes but slowed initial rendering in our Chrome
measurements. Attributes still use the runtime directive lookup, including
directives registered after compilation. Child bindings and directives precede parent attributes.
For AOT, assign the emitted expression to a generator with the current
GGenerator available as g, then pass that function directly as
new GTpl({ root, generator }). This needs no runtime evaluation. The imperative output requires the current runtime's
g.getDocument() and g.addAttributes() helpers; a custom g supplied to
GCompile must provide those helpers. Existing precompiled generators keep their
original g(tag, attrs, children, context) contract.
See the comparison and reproduction instructions.
State assignments and watch() callbacks are synchronous. DOM bindings are
queued by binding identity and run in a microtask, so repeated writes in the same
synchronous task calculate the final state once. Synchronous text, attributes,
styles and formulas do not allocate update promises after ready(); asynchronous
formulas remain supported and superseded results cannot overwrite newer output.
Synchronous ancestor updates reconcile before descendants, including nested lists invalidated
by replacing an outer row. Removed children cancel their queued bindings before
calculation; surviving children read the reconciled data in the same batch.
Asynchronous structural expressions keep existing children active until their
latest result resolves.
ready() still waits for the initial render and generated children. Call it after
mounting; it is not a general wait for later updates. A single nested list update
keeps the row's incremental path. Merged structural changes reconcile the final
list; explicit refresh() continues to refresh changes made outside proxies.
The scheduler and grid measurement protocol are documented in the runtime comparison.
If you use the global bundle (dist/gtpl.global.js):
<script src="dist/gtpl.global.js"></script>
<script>
const { GTpl, jit: { GCode, GCompile } } = gtpl;
</script>main tracks version 3.x. The 1.1.8 branch preserves the final pre-2.0
main commit; the existing v1.1.8 release tag retains its original commit.
Version 2.0 batches DOM binding updates in a microtask. State assignments and
watch() callbacks remain synchronous; code that reads the DOM immediately after
a state change must wait for the queued update. See reactive update batching
for nested bindings and ready() semantics. GCodeOptimized remains an opt-in
compiler backend; GCode remains the default reference backend.
When a GitHub Release is published, GitHub Actions runs tests, builds dist/, and uploads precompiled assets
(.zip and .tar.gz) to that release automatically:
https://github.com/garag-lib/GTPL/releases
To publish a new release:
# single command flow (tests + build + version bump + commit + push + tag push)
npm run release -- 3.0.0
# then publish release in GitHub UI for that tag (workflow triggers on publish)The release helper requires Node 24.15+ on the Node 24 line (or a version supported by the pinned Angular benchmark compiler). It changes the version before building and commits the versioned distribution, documentation and benchmark bundles together. Publishing the GitHub Release runs the same checks and attaches the compiled archives. npm publishing remains a separate command.
npm run build
npm testPackage outputs:
dist/gtpl.esm.jsdist/gtpl.cjsdist/gtpl.global.jsdist/gtpl.d.ts
g-innerwrites directly toinnerHTML; use trusted HTML only.- JIT compilation executes generated code (
GCompile), so compile only trusted template sources. - Templates are executable application code. Neither JIT nor AOT sanitizes HTML, URLs, or JavaScript, and precompiled generators are not sandboxed. Use templates and generators from a trusted source; sanitize untrusted HTML and bound URL/content values separately before rendering them.
The former Safe mode has been removed: GCodeSafe, GCodeOptimizedSafe,
GCompileSafe, and the allowFormulas, allowEvents, and allowInnerHTML
compiler options are no longer exported or supported.
For trusted templates, use GCode or GCodeOptimized and compile generated code
with GCompile when using JIT. For AOT, pass the precompiled function directly
to new GTpl({ root, generator }); this continues to work without dynamic
evaluation. Removing Safe does not make untrusted templates safe to execute.
If you use GTPL in a product or publication, a short attribution is appreciated:
Powered by GTPL (https://github.com/garag-lib/GTPL)
For formal attribution details, see NOTICE and CITATION.cff.
Apache-2.0