By default, Joystick apps use a more traditional, server-side rendering approach to routes. This is intentional, as most apps benefit more from this approach (for the sake of things like SEO, simplicity, and user experience) than the more common SPA (single page application) approach common to JavaScript frameworks.
For some apps, though, an SPA-style approach is better. For example, if you're building something like a media player where some elements are fixed, some are dynamic, SPA is a great pattern.
In Joystick, instead of taking an all-or-nothing approach, you can build an SPA-style experience on top of Joystck's existing routing system.
Example Usage
To showcase this functionality, we're going to build a simple "audio player" app that has a fixed audio player that's visible globally, with pages that are rendered dynamically in the browser (i.e., they don't require going to brand new URL or reloading the page).
Setting up your routes
Even though navigation in the browser will be dynamic, we still want to have our traditional, server-side rendered routes defined. Behind the scenes, Joystick will use the routes to help us with our dynamic rendering.
index.server.js
import joystick from "@joystick.js/node-canary";
import api from "./api/index.js";
joystick.app({
api,
routes: {
"/": (req = {}, res = {}) => {
res.render("ui/pages/index/index.js", {
layout: "ui/layouts/app/index.js",
});
},
"/artists": (req = {}, res = {}) => {
res.render("ui/pages/artists/index.js", {
layout: "ui/layouts/app/index.js",
});
},
"/albums": (req = {}, res = {}) => {
res.render("ui/pages/albums/index.js", {
layout: "ui/layouts/app/index.js",
});
},
"*": (req = {}, res = {}) => {
res.render("ui/pages/error/index.js", {
layout: "ui/layouts/app/index.js",
props: {
status_code: 404,
},
});
},
},
});
Above, we define our routes like normal, defining each of our page routes, rendering them inside of a layout. Notice that all pages being rendered are using the same layout located at ui/layouts/app/index.js
.
Defining your layout
ui/layouts/app/index.js
import joystick from "@joystick.js/ui-canary";
const App = joystick.component({
events: {
'click ul li a': (event = {}, instance = {}) => {
event.preventDefault();
instance.dynamic_page.load({
path: event.target.href,
page: event.target.getAttribute('data-page'),
query_params: {
user_id: 'abc123',
},
props: {
album_name: 'Future Perfect'
},
});
},
},
render: ({ props, dynamic_page_props, state, component }) => {
return `
<div>
<ul>
<li>
<a data-page="ui/pages/artists/index.js" href="/artists">Artists</a>
</li>
<li>
<a data-page="ui/pages/albums/index.js" href="/albums">Albums</a>
</li>
</ul>
${component(props.page, {
...props,
...dynamic_page_props,
})}
<audio controls src="/audio.mp3"></audio>
</div>
`;
},
});
export default App;
In the render()
method for our layout component, first, notice that like all layout components, we have a call to component()
passing props.page
. This is important: behind the scenes, Joystick's server-sider res.render()
and the client-side dynamic_page.load()
both pass the page-to-be-rendered to the current layout as props.page
.
This is intentional as it ensures that your app works both as a server-side rendered page (i.e., returning plain HTML for SEO purposes) and as a dynamic page.
If we look at the HTML surrounding our call to component(props.page)
we can see that we have an <audio></audio>
player at the bottom and then a simple navigation at the top. For the navigation, we have a normal <ul></ul>
list filled with <li>
tags containing <a>
tags.
Each <a>
tag is pointed at one of the routes we defined on the server, however, we've manually added a data-page
attribtue which contains the path to the page we'd like to render dynamically (notice, this is the same, ui
-relative path that we use in our server-side route).
Next, in our events
, we listen for a click on the <a>
tags in our navigation. When we get one, we call to instance.dynamic_page.load()
, a special function that, internally, fetches the page
we specify and uses the HTML5 Push State API to redirect to the specified path
. We achieve the effect of going to that route like normal, however, Joystick fetches the component dynamically behind the scenes and passes it to our layout as props.page
.
In turn, the user gets a snappy UX without a page refresh. The benefit in this example being that, if the user hits play on the <audio></audio>
player but then clicks on one of the nav links, the player will keep playing but the page will update.
Notice that in addition to our path
and page
options, we can also pass query_params
and props
directly to the route/component being rendered dynamically. This allows us to do things like utilize query params on the server to control behavior inside of our actual route at (dynamic) render time.
For the props
we pass to instance.dynamic_page.load()
, these are provided back to the page component being rendered via the special dynamic_page_props
that are passed to our layout component's render()
method.
Notice that when we render our page with component(props.page)
, we're passing both the layout component's props
along with the dynamic_page_props
. This ensures that the rendered page has all possible props—irrespective of whether we're rendering the page statically via a route, or dynamically via dynamic_page.load()
in the browser.
With all of the above, now, when a user clicks a navigation item, the specified data-page
will be dynamically loaded into the browser and the URL will be updated (all without a page refresh).
API
dynamic_page.load()
dynamic_page.load(options: object) => Promise
Parameters
- path string required
- The URL path that the browser will be redirected to at load time.
- page string required
-
The path to the page we want to dynamically render, relative to the
ui
folder in our app (identical to how we pass pages to res.render()) - route_pattern string
-
An Express-style route pattern (e.g.,
/blog/:slug
) that tells Joystick the anticipated structure of the dynamic page's route. Whendynamic_page.load()
is called, Joystick maps the specifiedpath
's structure to thisroute_pattern
, ensuring that any "dynamic" parts of the URL (e.g.,:slug
) are properly mapped to the current page. Only required if the specified route has parameters defined within it. - query_params object
- An object containing key/value pairs representing query params that you want to pass to the route at dynamic render time.
- props object
- An object containing key/value pairs representing props that you want to pass to the page at dynamic render time.