@joystick.js/ui

Dynamic Pages (SPA)

How to build an SPA-style app with Joystick using dynamic pages.

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. When dynamic_page.load() is called, Joystick maps the specified path's structure to this route_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.