Extend The Frontend
Jetonomy’s frontend runs on the WordPress Interactivity API (@wordpress/interactivity). The store is keyed 'jetonomy' and handles voting, navigation, bookmarks, moderation actions, and more. After every client-side page swap the store dispatches a jetonomy:navigated document event so companion scripts can re-initialize without duplicating nav logic. All REST calls go through a shared window.jetonomyRest.restFetch client.
This page explains how to extend the store, keep scripts alive across navigation, and call REST endpoints from your own JavaScript.
Source references:
- Store definition:
assets/js/view.js jetonomy:navigatedevent:assets/js/view.js:776- REST client:
assets/js/jetonomy-rest.js - Pagination hydration:
assets/js/pagination-hydrator.min.js - Interactivity standard:
docs/standards/frontend-interactivity.md
How the client-side router works
Section titled “How the client-side router works”Every click inside #jetonomy-app is delegated to actions.navigate. The action decides whether the target URL is safe to swap client-side:
- Most routes - swapped inside
[data-wp-router-region="jetonomy/main"]without a full reload. - Rich-editor routes (
/s/{slug}/t/{slug}/single topic,/s/{slug}/new/new post) - forced to full-page load because the composer and Prism.js bind onDOMContentLoadedand do not re-init on swap.
After each swap the navigate action dispatches jetonomy:navigated on document. Every companion script that targets region content must listen to this event; DOMContentLoaded alone is not reliable for region content.
Extending the Interactivity API store
Section titled “Extending the Interactivity API store”Import store from @wordpress/interactivity inside a script module and pass the 'jetonomy' namespace to extend the existing store. New state, actions, and callbacks you add merge with the plugin’s own entries; you do not replace them.
Register your module with wp_enqueue_script_module and declare jetonomy-view as a dependency so it loads after the core store.
// In your plugin's PHP boot file:add_action( 'wp_enqueue_scripts', function () { wp_enqueue_script_module( 'my-plugin-view', MY_PLUGIN_URL . 'assets/js/my-view.js', array( 'jetonomy-view' ), // depends on the core store module MY_PLUGIN_VERSION );} );// assets/js/my-view.js (ES module - loaded as a script module)import { store, getContext } from '@wordpress/interactivity';
const { state, actions } = store( 'jetonomy', { state: { // Extend the shared state with your own reactive keys. myPluginPanelOpen: false, },
actions: { toggleMyPanel() { state.myPluginPanelOpen = ! state.myPluginPanelOpen; }, },} );Then bind your action declaratively in a template (or a template override):
<button data-wp-interactive="jetonomy" data-wp-on--click="actions.toggleMyPanel" data-wp-bind--aria-expanded="state.myPluginPanelOpen"> Toggle</button><div data-wp-bind--hidden="!state.myPluginPanelOpen"> My plugin panel content</div>Declarative controls auto-hydrate after every client-side swap - no re-init code needed.
Re-initializing after jetonomy:navigated
Section titled “Re-initializing after jetonomy:navigated”Classic (non-module) scripts that need to wire up DOM nodes in the router region must listen to jetonomy:navigated alongside the initial load. This event fires immediately after each content swap.
The pattern is: guard your init function so it only wires freshly-swapped nodes (idempotent), and bind it to both startup and navigation.
function initMyPlugin() { // Only target unwired nodes so re-running after navigation is safe. document.querySelectorAll( '.my-widget:not([data-my-wired])' ).forEach( function ( el ) { el.dataset.myWired = '1'; el.addEventListener( 'click', function () { // handle click } ); } );}
// Startup - guard for cases where the DOM is already ready.if ( document.readyState === 'loading' ) { document.addEventListener( 'DOMContentLoaded', initMyPlugin );} else { initMyPlugin();}
// Re-run after every client-side navigation.document.addEventListener( 'jetonomy:navigated', initMyPlugin );The jetonomy:navigated event is a CustomEvent with detail.href set to the navigated URL:
document.addEventListener( 'jetonomy:navigated', function ( event ) { console.log( 'navigated to', event.detail.href ); initMyPlugin();} );Do NOT use
DOMContentLoadedalone for content inside the router region. It fires once on the initial page load and never again after a client-side swap.
Complete worked example: “Quick-note” button that survives navigation
Section titled “Complete worked example: “Quick-note” button that survives navigation”This example adds a per-post “Quick note” button to post cards (via a template override or the jetonomy_post_card_after_badges hook) that opens a small text box and saves the note to the REST API.
1. Register the script module
Section titled “1. Register the script module”add_action( 'wp_enqueue_scripts', function () { wp_enqueue_script_module( 'my-quick-note', MY_PLUGIN_URL . 'assets/js/quick-note.js', array( 'jetonomy-view' ), MY_PLUGIN_VERSION );} );2. The script module (quick-note.js)
Section titled “2. The script module (quick-note.js)”import { store } from '@wordpress/interactivity';
store( 'jetonomy', { state: { quickNoteOpen: {}, // keyed by post ID quickNoteText: {}, },
actions: { toggleQuickNote() { const postId = this.context.postId; const current = state.quickNoteOpen[ postId ] ?? false; state.quickNoteOpen[ postId ] = ! current; },
async saveQuickNote() { const postId = this.context.postId; const text = state.quickNoteText[ postId ] ?? ''; if ( ! text.trim() ) return;
const result = await window.jetonomyRest.restFetch( '/my-plugin/notes/' + postId, { method: 'POST', body: { note: text }, } );
if ( result.ok ) { state.quickNoteOpen[ postId ] = false; } }, },} );3. Markup added via hook
Section titled “3. Markup added via hook”add_action( 'jetonomy_post_card_after_badges', function ( $post ) { printf( '<div data-wp-interactive="jetonomy" data-wp-context=\'{"postId": %d}\'> <button class="jt-btn jt-btn--ghost" data-wp-on--click="actions.toggleQuickNote">Note</button> <div data-wp-bind--hidden="!state.quickNoteOpen[context.postId]"> <textarea data-wp-bind--value="state.quickNoteText[context.postId]" data-wp-on--input="state.quickNoteText[context.postId] = event.target.value"> </textarea> <button class="jt-btn jt-btn--primary" data-wp-on--click="actions.saveQuickNote">Save</button> </div> </div>', (int) $post->id );}, 10, 1 );Because both the state and the actions are declared in the 'jetonomy' store, the Interactivity API automatically re-hydrates the directives after every client-side navigation - no jetonomy:navigated listener is needed here.
Using window.jetonomyRest.restFetch
Section titled “Using window.jetonomyRest.restFetch”window.jetonomyRest.restFetch is a shared REST client available on every Jetonomy community page. It:
- Resolves the REST base URL and nonce from
window.jetonomyData. - Sends
credentials: 'same-origin'and theX-WP-Nonceheader on every request. - JSON-encodes plain-object bodies and sets
Content-Type: application/json. - On
403 rest_cookie_invalid_nonceresponses, fetches/jetonomy/v1/auth/nonceto refresh the nonce and retries the original request automatically. - Never throws - always resolves to
{ ok: boolean, status: number, data: any }.
Signature:
const result = await window.jetonomyRest.restFetch( path, options );// path - string, e.g. '/posts/42/vote' or 'posts/42' (leading slash optional)// options - fetch-compatible object: method, body, headers, etc.// result - { ok, status, data }Reading example:
const result = await window.jetonomyRest.restFetch( '/spaces/12/posts?per_page=5' );if ( result.ok ) { const posts = result.data.items;}Mutation example:
const result = await window.jetonomyRest.restFetch( '/posts/42/vote', { method: 'POST', body: { direction: 'up' },} );if ( ! result.ok ) { console.error( 'Vote failed', result.status, result.data );}window.jetonomyHydrateInteractive
Section titled “window.jetonomyHydrateInteractive”When you append new DOM nodes that contain data-wp-on--click directives (for example, via a “Load More” fetch), the Interactivity API does not automatically re-wire those nodes. Call window.jetonomyHydrateInteractive( nodes ) with an array of the newly appended elements and the pagination hydrator will make their click handlers work.
fetch( nextPageUrl ) .then( r => r.text() ) .then( html => { const doc = new DOMParser().parseFromString( html, 'text/html' ); const newItems = Array.from( doc.querySelectorAll( '.jt-row' ) ); const list = document.querySelector( '.jt-topics' );
newItems.forEach( item => list.appendChild( item ) );
if ( typeof window.jetonomyHydrateInteractive === 'function' ) { window.jetonomyHydrateInteractive( newItems ); } } );This is used internally by the Load More pagination handler (assets/js/pagination-frontend.min.js).
Compliance checklist
Section titled “Compliance checklist”Before shipping any frontend feature, verify against the Frontend Interactivity Standard (docs/standards/frontend-interactivity.md in the plugin repo):
- No
DOMContentLoaded-only handler targeting region content without ajetonomy:navigatedpair. - No
wp_add_inline_script/ inline<script>driving region behavior. - No raw
fetch()calls - usewindow.jetonomyRest.restFetchinstead. - Interactive controls use
data-wp-on--*store actions wherever possible. - Verify the feature after a client-side navigation, not only after a full page load.
What’s next
Section titled “What’s next”- Theming and Tokens - CSS custom properties your JS can read
- Extend the REST API - add REST endpoints your
restFetchcalls can hit - Hooks Reference - PHP hooks for injecting markup into templates

