Quick start
Stride is an ES module. There is no required installer or build step.
<style>
[s-cloak] { display: none !important; }
</style>
<div id="counter" s-cloak>
<button s-click="decrement">-</button>
<strong s-text="count"></strong>
<button s-click="increment">+</button>
</div>
<script type="module">
import Stride from 'https://cdn.stridejs.com/v1/stride.min.js';
Stride.create('#counter', {
state: { count: 0 },
methods: {
increment() { this.state.count++; },
decrement() { this.state.count--; }
}
});
</script>
s-cloak on a component whose initial HTML should remain hidden until Stride performs its first render.Mental model
Stride.create() owns one DOM root. State and methods belong to that root, while an optional global store and event bus can connect otherwise independent components.
| Layer | Purpose |
|---|---|
| HTML | Normal markup plus small s-* directives. |
| Component state | Local reactive data for one Stride root. |
| Methods | Plain JavaScript functions invoked by directives or lifecycle hooks. |
| Global store | Optional shared reactive state and shared methods. |
| Event bus | Optional message passing without direct component references. |
Stride uses direct DOM operations rather than a virtual DOM. Reactive writes are batched into a microtask so several synchronous state changes normally produce one render before the browser paints.
Stride.create()
const app = await Stride.create('#app', {
state: { message: 'Hello' },
methods: {
clear() { this.state.message = ''; }
},
mounted() {},
updated() {},
unmounted() {}
});
console.log(app.state.message);
Inside component methods, this exposes:
this.state— local reactive state.this.store— the global store, when configured.this.storeMethods— global store methods.this.emit(),this.on(),this.off()— event bus helpers.this.fetch()— Stride's interceptable fetch wrapper.this.methods— the component's bound method collection.
Calling create() again on an already-active root returns its existing context instead of attaching duplicate listeners.
Reactive state
Plain objects and arrays inside state are exposed through cached native Proxy objects. Nested mutations such as push(), property assignment, and deletion schedule a render.
Stride.create('#app', {
state: {
user: { name: 'Ada' },
items: []
},
methods: {
update() {
this.state.user.name = 'Grace';
this.state.items.push({ name: 'Compiler' });
// Both writes are batched into the same render turn.
}
}
});
Date, Map, Set, RegExp, and user-defined class instances—are preserved by reference so their native behavior remains intact.Mutating one of those preserved objects internally does not itself pass through Stride's reactive proxy and therefore does not automatically schedule a render. Assign a replacement value to the containing reactive property when the UI needs to react to that change:
// Native mutation remains native, but does not itself schedule a Stride render.
this.state.cache.set('name', 'Stride');
// Replacing the reactive property does schedule a render.
this.state.cache = new Map(this.state.cache).set('name', 'Stride');
updated() rather than reading the DOM immediately after an assignment.Bindings resolve local state first. Most directives can also explicitly reference shared state with the store. prefix.
Directive reference
<span s-text="user.name"></span>
<span s-text="store.status"></span>true adds an empty boolean attribute; false, null, or undefined removes it; other values are converted to strings.
<a s-attr:href="profileUrl">Profile</a>
<button s-attr:disabled="isSaving">Save</button><div class="panel" s-class:is-active="active"></div><button s-click="save">Save</button>
<button s-click="load('/account.html')">Load</button>The current inline expression format supports either no argument or one literal argument. Without an inline argument, the native click event is passed as the first method argument.
<a href="/fallback" s-click.prevent="openPanel">Open panel</a><select s-change="changeEnvironment">...</select><input s-keyup:enter="submitSearch">
<input s-keyup:escape="closeSearch">When false, the element is physically detached from the live DOM and a comment anchor preserves its insertion point. Stride intentionally retains the detached node internally so the same node can be reinserted later; this is DOM detachment, not a claim that the node itself is garbage-collected.
Nested Stride components inside that subtree are also intentionally preserved so they can resume if the condition becomes true again. If application logic knows a nested component will never return, explicitly call Stride.destroy() for that nested component before permanently abandoning the conditional subtree.
<aside s-if="showNotice">Saved.</aside>Stride switches the element's inline display value to none !important while hidden and restores its prior inline display value when shown again. It does not override an unrelated external stylesheet rule that independently keeps the element hidden.
<div s-show="menuOpen">...</div>[s-cloak] { display: none !important; }
<div id="app" s-cloak>...</div><section s-include="/components/account-card.html"></section><div s-if="open" s-transition="fade" class="fade">...</div>Loops
<ul>
<li s-loop="items">
<span s-text="index"></span>
<input s-model="item.name">
<button s-click="selectItem">
<span s-text="item.name"></span>
</button>
</li>
</ul>
Inside the repeated element:
itemis the current array value.item.nameresolves a property of the current item.indexis the zero-based item index.itemandindexare loop aliases only inside a rendereds-looprow; outside a loop, state properties literally nameditemorindexwork normally.s-text,s-attr:,s-class:,s-show, events,s-model="item.*", and bares-model="item"can be used inside the loop.
Stride reuses an existing rendered loop node when the item at that index is the same object/value, and updates its bindings in place. Insertions/removals/order changes update only affected repeated nodes rather than blindly deleting every row on every unrelated state write.
Primitive arrays can also be edited directly with a bare item model:
<div s-loop="tags">
<input s-model="item">
</div>
Stride.create('#app', {
state: { tags: ['fast', 'small', 'native'] }
});
s-loop or s-if) inside an active loop template row. If your dataset requires complex nested trees, cleanly isolate that layer into a separate Stride component container, or utilize high-speed inline utilities like s-show or s-class inside the repeated row.
Models & forms
| Control | State value |
|---|---|
| Text, textarea, select | String |
| Checkbox | Boolean |
| Radio | Selected radio's string value |
| Multiple select | Array of selected string values |
<input s-model="name">
<input type="checkbox" s-model="accepted">
<input s-model="store.search">
<input s-model="item.name">
<input s-model="item"> <!-- primitive array item inside s-loop -->
<form s-submit="register" novalidate>
<input name="username" required minlength="5">
<input name="email" type="email" required>
<button type="submit">Register</button>
</form>
Stride.create('#account', {
methods: {
register(payload, event) {
console.log(payload.username, payload.email);
}
}
});
If validation fails, Stride adds s-form-invalid to the form and s-input-error plus aria-invalid="true" to invalid inputs, selects, and textareas. Provide CSS for the visual state you want.
.s-input-error { border-color: #ef4444; }
If several successful controls have the same name, the payload value becomes an array rather than silently discarding earlier values.
Transitions
s-transition="fade" is intentionally CSS-driven. Stride only coordinates lifecycle timing.
.fade {
opacity: 1;
transform: translateY(0);
transition: opacity .25s ease, transform .25s ease;
}
.fade-enter {
opacity: 0;
transform: translateY(-8px);
}
.fade-leave {
opacity: 0;
transform: translateY(8px);
}
<div s-if="open" s-transition="fade" class="fade">...</div>
Stride calculates the browser's transition duration/delay and waits before detaching or hiding a leaving element. If state flips back while a leave is pending, the leave is cancelled instead of removing a now-visible element.
Global store
Stride.store({
state: {
user: null,
notifications: 0
},
methods: {
clearNotifications() {
this.state.notifications = 0;
}
}
});
Read shared values from HTML with the explicit store. namespace:
<span s-text="store.notifications"></span>
<button s-click="clearNotifications">Clear</button>
Inside a local method, use this.store and this.storeMethods:
methods: {
openInbox() {
console.log(this.store.notifications);
this.storeMethods.clearNotifications();
}
}
Store writes schedule refreshes for registered Stride components. A component temporarily detached by s-if intentionally remains registered so it can stay current and resume if the same subtree is reinserted. Stride.destroy() removes a component from that update list; Stride also performs teardown when its own router/include/loop operations permanently discard owned component content.
element.remove() or by replacing an ancestor's innerHTML, call Stride.destroy() first. Otherwise the detached component can remain registered and strongly reachable—including its state and subscriptions—and can continue receiving store-driven renders. If nothing else releases it, that is a persistent memory leak.Stride.store() is a singleton initializer. A later call returns the existing store; if that later call supplies new state or methods, Stride logs a warning because those values are intentionally ignored.
Event bus
const stop = Stride.on('toast', payload => {
console.log(payload.message);
});
Stride.emit('toast', { message: 'Saved' });
stop();
The same helpers are available as this.on, this.off, and this.emit inside component methods and lifecycle hooks. Subscriptions created with component-scoped this.on() are automatically unsubscribed when that component is destroyed. A top-level Stride.on() subscription is global and remains the caller's responsibility to unsubscribe.
HTML includes
s-include fetches reusable HTML before Stride scans the component for directives. Multiple includes at the same level are fetched concurrently, and nested includes are then resolved recursively.
<div id="account">
<header s-include="/parts/header.html"></header>
<section s-include="/parts/account.html"></section>
</div>
innerHTML. Because client-side JavaScript operates strictly within the browser's sandbox environment, s-include cannot access or expose any server file that isn't already publicly readable by the end-user's web browser directly.
Best Practices: Ensure your fragment paths don't expose administrative file locations to public browser inspection tools. As an industry baseline, front-end layers are visual highways; your backend server environment (PHP, Go, Python, C++, etc.) must always serve as the final judge and absolute filter for data validation and access control.
Stride.fetch() & interceptors
Stride.fetch(url, config) wraps the browser's native fetch() and optionally runs one global request interceptor and one global response interceptor. Both may be synchronous or asynchronous.
Stride.interceptors.request = async config => {
const token = await getToken();
return {
...config,
headers: { ...config.headers, Authorization: `Bearer ${token}` }
};
};
Stride.interceptors.response = async response => {
if (response.status === 401) showLogin();
return response;
};
Stride.create('#profile', {
methods: {
async load() {
const response = await this.fetch('/api/profile');
const data = await response.json();
this.state.name = data.name;
}
}
});
s-include and the Stride router also use this same fetch pipeline, so shared authentication/error logic can apply consistently.
Router
The router is a small same-origin fragment router. It updates browser history, supports back/forward navigation, dynamic path parameters, a 404 fragment, cancellation of stale in-flight navigations, and an afterEach hook.
<nav>
<a href="/home" s-route>Home</a>
<a href="/profile/ada" s-route>Ada</a>
</nav>
<main s-view>Initial shell content</main>
<script type="module">
import Stride from 'https://cdn.stridejs.com/v1/stride.min.js';
Stride.router({
routes: {
'/home': '/views/home.html',
'/profile/:username': '/views/profile.html',
'/404': '/views/404.html'
},
// Optional: set true when a direct /profile/ada request should
// resolve immediately instead of preserving the server-rendered shell.
resolveInitial: false,
afterEach({ path, params, view, initial }) {
console.log(path, params);
}
});
</script>
Route links
s-route can be an empty marker and use the anchor's href, which is recommended for progressive fallback:
<a href="/about" s-route>About</a>
If s-route contains a non-empty value, that value is preferred as the SPA destination. Keep it aligned with href unless you intentionally want different fallback behavior.
Dynamic parameters
A route such as /profile/:username exposes decoded params through Stride.routeParams. If a global store exists, it also receives store.routeParams.
Fetched route fragments can use escaped text placeholders:
<h1>Profile: {{username}}</h1>
Route parameter replacements are HTML-escaped before insertion.
Initial navigation
By default, Stride.router() leaves the existing s-view contents untouched when the router is initialized. This is useful when the server-rendered page or application shell already contains the correct initial content.
Set resolveInitial: true when a direct request to a configured client route such as /profile/ada should fetch and render its route fragment immediately on boot. History back/forward and later s-route clicks resolve normally in either mode.
Route completion event
Stride.on('route:loaded', ({ path, params, view }) => {
// Initialize route-specific behavior here if needed.
});
/profile/ada require your web server to return the application shell for application routes on a full browser refresh.Lifecycle hooks
Stride.create('#chart', {
state: { points: [] },
mounted() { drawChart(this.state.points); },
updated() { drawChart(this.state.points); },
unmounted() { console.log('Stride-managed node removed'); }
});
unmounted() is also used by s-if, a component containing several conditional removals may call it more than once during its lifetime. Keep the hook safe to run repeatedly.s-if: detaching an s-if subtree is treated as temporary. Nested Stride component roots inside that retained subtree stay registered rather than being destroyed automatically. If your application knows such a nested component will never be shown again, explicitly destroy that nested component before the subtree is permanently abandoned.Cleanup
Stride.destroy('#temporary-widget');
Use Stride.destroy() before application code permanently removes a component root with native DOM APIs. Skipping destruction is not only a missed lifecycle callback: Stride can retain a strong reference to the detached component, so its state/subscriptions remain reachable and store writes can continue scheduling work against an unreachable DOM subtree.
Stride.destroy(root) before root.remove(), replacing an ancestor with innerHTML, or another application-controlled permanent removal. Stride can automatically clean up permanent removals performed by its own router/include/loop machinery, but it cannot infer the intent behind arbitrary DOM code outside the library.Component-scoped listeners registered with this.on() are removed automatically by Stride.destroy(). Top-level listeners created with Stride.on() are global; retain their unsubscribe function and call it when that global listener is no longer needed.
Semantics & current limits
- Modern browser library: Stride does not provide an IE-compatible fallback runtime.
- Nested component roots: independent sibling roots are the cleanest composition model. Initialized child roots are respected as component boundaries, so later event/render propagation does not cross into them.
- Nested structural ownership: an ancestor scans its descendants for structural
s-loopands-ifdirectives during initialization. If a nested child's own structural template is still ordinary, unclaimed markup at that time, the ancestor can claim and physically extract it before the child is initialized. Do not rely on a later child initialization to reclaim structural markup an ancestor has already processed. Prefer sibling component roots or keep structural ownership within one component. - Structural directives belong below the root: place
s-loopands-ifon descendants of the element passed toStride.create(), not on the component root itself. - Nested structural loop directives: do not nest
s-loopor structurals-ifinside ans-looptemplate in v1.2.6. For complex nested trees, isolate the nested layer into another appropriately owned component or use non-structural bindings such ass-showors-class. - Retained
s-ifsubtrees:s-ifis intentionally reversible. Nested components inside a detached conditional subtree remain alive so the same subtree can return. If application logic makes that removal permanent, explicitly destroy nested components that will never return. - Manual DOM removal requires cleanup: before permanently removing a component root outside Stride's own router/include/loop operations, call
Stride.destroy(). Otherwise the detached component may remain registered and strongly reachable, which can become a persistent memory leak. - Deep reactivity boundary: plain objects and arrays are deeply reactive. Native objects and class instances are preserved by reference; mutating their internal state does not itself schedule a render, while replacing the containing reactive property does.
- One global store:
Stride.store()creates a singleton shared store for the module instance. - One literal click argument: directive expressions are intentionally not a JavaScript evaluator.
- HTML fragments are trusted markup:
s-includeand route fragments useinnerHTML. - No general moustache templating: use
s-textfor reactive values.{{param}}substitution is specifically a router-fragment convenience for route parameters. - No automatic type coercion for ordinary text controls: text/select values remain strings; checkbox and multiple-select have the special model behavior documented above.
Browser requirements
Stride targets modern evergreen browsers and relies on standard platform features including ES modules, Proxy, fetch, FormData, URL, queueMicrotask, requestAnimationFrame, and the History API.
If you need to support a specific browser/version matrix, test that matrix as part of your release process rather than assuming support from the library name alone.