Simple, Modern JavaScript

Simple, Modern JavaScript Background
15 min read

JavaScript has progressed significantly in recent times where many of the tooling & language enhancements that we used to rely on external tools for is now available in modern browsers alleviating the need for complex tooling and npm dependencies that have historically plagued modern web development.

The good news is that the complex npm tooling that was previously considered mandatory in modern JavaScript App development can be considered optional as we can now utilize modern browser features like async/await, JavaScript Modules, dynamic imports, import maps and modern language features for a sophisticated development workflow without the need for any npm build tools.

Bringing Simplicity Back

The razor template focuses on simplicity and eschews many aspects that has complicated modern JavaScript development, specifically:

  • No npm node_modules or build tools
  • No client side routing
  • No heavy client state

Effectively abandoning the traditional SPA approach in lieu of a simpler MPA development model using Razor Pages for Server Rendered content with any interactive UIs progressively enhanced with JavaScript.

Freedom to use any JS library

Avoiding the SPA route ends up affording more flexibility on which JS libraries each page can use as without heavy bundled JS blobs of all JS used in the entire App, it's free to only load the required JS each page needs to best implement its required functionality, which can be any JS library, preferably utilizing ESM builds that can be referenced from a JavaScript Module, taking advantage of the module system native to modern browsers able to efficiently download the declarative matrix of dependencies each script needs.

Best libraries for progressive Multi Page Apps

It includes a collection of libraries we believe offers the best modern development experience in Progressive MPA Web Apps, specifically:

Tailwind CLI

Tailwind enables a responsive, utility-first CSS framework for creating maintainable CSS at scale without the need for any CSS preprocessors like Sass, which is configured to run from an npx script to avoid needing any node_module dependencies.

Vue 3

Vue is a popular Progressive JavaScript Framework that makes it easy to create interactive Reactive Components whose Composition API offers a nice development model without requiring any pre-processors like JSX.

Where creating a component is as simple as:

const Hello = {
    template: `<b>Hello, {‎{name}‎}!</b>`,
    props: { name:String }
}

Or a simple reactive example:

import { ref } from "vue"

const Counter = {
    template: `<b @click="count++">Counter {‎{count}‎}</b>`,
    setup() {
        let count = ref(1)
        return { count }
    }
}

Vue Components in Markdown

Inside .md Markdown pages Vue Components can be embedded using Vue's progressive HTML Template Syntax:

<hello name="Vue 3"></hello>
<counter></counter>

Vue Components in Razor Pages

Inside .cshtml Razor Pages these components can be mounted using the standard Vue 3 mount API, but to make it easier we've added additional APIs for declaratively mounting components to pages using data-component and data-props attributes:

<div data-component="Hello" data-props="{ name: 'Vue 3' }"></div>

Alternatively they can be programatically added using the custom mount method in api.mjs:

import { mount } from "/mjs/api.mjs"
mount('#counter', Counter)

Both methods create components with access to all your Shared Components and any 3rd Party Plugins which we can preview in this example that uses @servicestack/vue's PrimaryButton and ModalDialog:

const Plugin = {
    template:`<div>
        <PrimaryButton @click="show=true">Open Modal</PrimaryButton>
        <ModalDialog v-if="show" @done="show=false">
            <div class="p-8">Hello @servicestack/vue!</div>
        </ModalDialog>
    </div>`,
    setup() {
        const show = ref(false)
        return { show }
    }
}
<plugin></plugin>

Vue HTML Templates

An alternative progressive approach for creating Reactive UIs with Vue is by embedding its HTML markup directly in .html pages using HTML Template Syntax which is both great for performance as the DOM UI can be rendered before the Vue Component is initialized. UI elements you want hidden can use Vue's v-cloak attribute where they'll be hidden until components are initialized.

It's also great for development as it lets you cohesively maintain most pages functionality need in the HTML page itself - in isolation with the rest of the website, i.e. instead of spread across multiple external .js source files that for SPAs unnecessarily increases the payload sizes of JS bundles with functionality that no other pages need.

With Vue's HTML syntax you can maintain the Vue template in HTML and just use embedded JavaScript for the Reactive UI's functionality, e.g:

<div id="app">
    <primary-button v-on:click="show=true">Open Modal</primary-button>
    <modal-dialog v-if="show" v-on:done="show=false">
        <div class="p-8">Hello @servicestack/vue!</div>
    </modal-dialog>
</div>
<script>
const App = {
    setup() {
        const show = ref(false)
        return { show }
    }
}
mount('#app', App)
</script>

This is the approach used to develop Vue Stable Diffusion where all functionality specific to the page is maintained in the page itself, whilst any common functionality is maintained in external JS Modules loaded on-demand by the Browser when needed.

@servicestack/vue

@servicestack/vue is our growing Vue 3 Tailwind component library with a number of rich Tailwind components useful in .NET Web Apps, including Input Components with auto form validation binding which is used by all HTML forms in the razor template.

@servicestack/client

@servicestack/client is our generic JS/TypeScript client library which enables a terse, typed API for using your App's typed DTOs from the built-in JavaScript ES6 Classes support to enable an effortless end-to-end Typed development model for calling your APIs without any build steps, e.g:

<input type="text" id="txtName">
<div id="result"></div>

<script type="module">
import { JsonApiClient, $1, on } from '@servicestack/client'
import { Hello } from '/types/mjs'

on('#txtName', {
    async keyup(el) {
        const client = JsonApiClient.create()
        const api = await client.api(new Hello({ name:el.target.value }))
        $1('#result').innerHTML = api.response.result
    }
})
</script>

For better IDE intelli-sense during development, save the annotated Typed DTOs to disk with:

npm run dtos

That can be referenced instead to unlock your IDE's static analysis type-checking and intelli-sense benefits during development:

import { Hello } from '/js/dtos.mjs'
client.api(new Hello({ name }))

You'll typically use all these libraries in your API-enabled components as seen in the HelloApi.mjs component on the home page which calls the Hello API on each key press:

import { ref } from "vue"
import { useClient } from "@servicestack/vue"
import { Hello } from "../dtos.mjs"

export default {
    template:/*html*/`<div class="flex flex-wrap justify-center">
        <TextInput v-model="name" @keyup="update" />
        <div class="ml-3 mt-2 text-lg">{‎{ result }‎}</div>
    </div>`,
    props:['value'],
    setup(props) {
        let name = ref(props.value)
        let result = ref('')
        let client = useClient()

        async function update() {
            let api = await client.api(new Hello({ name }))
            if (api.succeeded) {
                result.value = api.response.result
            }
        }
        update()

        return { name, update, result }
    }
}

Which we can also mount below:

<hello-api value="Vue 3"></hello-api>

We'll also go through and explain other features used in this component:

/*html*/

Although not needed in Rider (which can automatically infer HTML in strings), the /*html*/ type hint can be used to instruct tooling like the es6-string-html VS Code extension to provide syntax highlighting and an enhanced authoring experience for HTML content in string literals.

useClient

useClient() provides managed APIs around the JsonServiceClient instance registered in Vue App's with:

let client = JsonApiClient.create()
app.provide('client', client)

Which maintains contextual information around your API calls like loading and error states, used by @servicestack/vue components to enable its auto validation binding. Other functionality in this provider include:

let { 
    api, apiVoid, apiForm, apiFormVoid, // Managed Typed ServiceClient APIs
    loading, error,                     // Maintains 'loading' and 'error' states
    setError, addFieldError,            // Add custom errors in client
    unRefs                              // Returns a dto with all Refs unwrapped
} = useClient()

Typically you would need to unwrap ref values when calling APIs, i.e:

let client = JsonApiClient.create()
let api = await client.api(new Hello({ name:name.value }))

useClient - api

This is unnecessary in useClient api* methods which automatically unwraps ref values, allowing for the more pleasant API call:

let api = await client.api(new Hello({ name }))

useClient - unRefs

But as DTOs are typed, passing reference values will report a type annotation warning in IDEs with type-checking enabled, which can be resolved by explicitly unwrapping DTO ref values with unRefs:

let api = await client.api(new Hello(unRefs({ name })))

useClient - setError

setError can be used to populate client-side validation errors which the SignUp.mjs component uses to report an invalid submissions when passwords don't match:

const { api, setError } = useClient()
async function onSubmit() {
    if (password.value !== confirmPassword.value) {
        setError({ fieldName:'confirmPassword', message:'Passwords do not match' })
        return
    }
    //...
}

Form Validation

All @servicestack/vue Input Components support contextual validation binding that's typically populated from API Error Response DTOs but can also be populated from client-side validation as done above.

Explicit Error Handling

This populated ResponseStatus DTO can either be manually passed into each component's status property as done in /TodoMvc:

<template id="TodoMvc-template">
    <div class="mb-3">
        <text-input :status="store.error" id="text" label="" placeholder="What needs to be done?"
                    v-model="store.newTodo" v-on:keyup.enter.stop="store.addTodo()"></text-input>
    </div>
    <!-- ... -->
</template>

Where if you try adding an empty Todo the CreateTodo API will fail and populate its store.error reactive property with the APIs Error Response DTO which the <TextInput /> component checks to display any field validation errors adjacent to the HTML Input with matching id fields:

let store = {
    /** @type {Todo[]} */
    todos: [],
    newTodo:'',
    error:null,
    async addTodo() {
        this.todos.push(new Todo({ text:this.newTodo }))
        let api = await client.api(new CreateTodo({ text:this.newTodo }))
        if (api.succeeded)
            this.newTodo = ''
        else
            this.error = api.error
    },
    //...
}

Implicit Error Handling

More often you'll want to take advantage of the implicit validation support in useClient() which makes its state available to child components, alleviating the need to explicitly pass it in each component as seen in razor's Contacts.mjs Edit component for its Contacts page which doesn't do any manual error handling:

const Edit = {
    template:/*html*/`<SlideOver @done="close" title="Edit Contact">
    <form @submit.prevent="submit">
      <input type="submit" class="hidden">
      <fieldset>
        <ErrorSummary except="title,name,color,filmGenres,age,agree" class="mb-4" />
        <div class="grid grid-cols-6 gap-6">
          <div class="col-span-6 sm:col-span-3">
            <SelectInput id="title" v-model="request.title" :options="enumOptions('Title')" />
          </div>
          <div class="col-span-6 sm:col-span-3">
            <TextInput id="name" v-model="request.name" required placeholder="Contact Name" />
          </div>
          <div class="col-span-6 sm:col-span-3">
            <SelectInput id="color" v-model="request.color" :options="colorOptions" />
          </div>
          <div class="col-span-6 sm:col-span-3">
            <SelectInput id="favoriteGenre" v-model="request.favoriteGenre" :options="enumOptions('FilmGenre')" />
          </div>
          <div class="col-span-6 sm:col-span-3">
            <TextInput type="number" id="age" v-model="request.age" />
          </div>
        </div>
      </fieldset>
    </form>
    <template #footer>
      <div class="flex justify-between space-x-3">
        <div><ConfirmDelete @delete="onDelete">Delete</ConfirmDelete></div>
        <div><PrimaryButton @click="submit">Update Contact</PrimaryButton></div>
      </div>
    </template>
  </SlideOver>`,
    props:['contact'],
    emits:['done'],
    setup(props, { emit }) {
        const client = useClient()
        const request = ref(new UpdateContact(props.contact))
        const colorOptions = propertyOptions(getProperty('UpdateContact','Color'))

        async function submit() {
            const api = await client.api(request.value)
            if (api.succeeded) close()
        }

        async function onDelete () {
            const api = await client.apiVoid(new DeleteContact({ id:props.id }))
            if (api.succeeded) close()
        }

        const close = () => emit('done')

        return { request, enumOptions, colorOptions, submit, onDelete, close }
    }
}

Effectively making form validation binding a transparent detail where all @servicestack/vue Input Components are able to automatically apply contextual validation errors next to the fields they apply to:

AutoForm Components

We can elevate our productivity even further with Auto Form Components that can automatically generate an instant API-enabled form with validation binding by just specifying the Request DTO you want to create the form of, e.g:

<AutoCreateForm type="CreateBooking" formStyle="card" />

The AutoForm components are powered by your App Metadata which allows creating highly customized UIs from declarative C# attributes whose customizations are reused across all ServiceStack Auto UIs, including:

Form Input Components

In addition to including Tailwind versions of the standard HTML Form Inputs controls to create beautiful Tailwind Forms, it also contains a variety of integrated high-level components:

useAuth

Your Vue.js code can access Authenticated Users using useAuth() which can also be populated without the overhead of an Ajax request by embedding the response of the built-in Authenticate API inside _Layout.cshtml with:

<script type="module">
import { useAuth } from "@@servicestack/vue"
const { signIn } = useAuth()
signIn(@await Html.ApiAsJsonAsync(new Authenticate()))
</script>

Where it enables access to the below useAuth() utils for inspecting the current authenticated user:

const { 
    signIn,           // Sign In the currently Authenticated User
    signOut,          // Sign Out currently Authenticated User
    user,             // Access Authenticated User info in a reactive Ref<AuthenticateResponse>
    isAuthenticated,  // Check if the current user is Authenticated in a reactive Ref<boolean>
    hasRole,          // Check if the Authenticated User has a specific role
    hasPermission,    // Check if the Authenticated User has a specific permission
    isAdmin           // Check if the Authenticated User has the Admin role
} = useAuth()

This is used in Bookings.mjs to control whether the <AutoEditForm> component should enable its delete functionality:

export default {
    template/*html*/:`
    <AutoEditForm type="UpdateBooking" :deleteType="canDelete ? 'DeleteBooking' : null" />
    `,
    setup(props) {
        const { hasRole } = useAuth()
        const canDelete = computed(() => hasRole('Manager'))
        return { canDelete }
    }
}

JSDoc

We get great value from using TypeScript to maintain our libraries typed code bases, however it does mandate using an external tool to convert it to valid JS before it can be run, something the new Razor Vue.js templates expressly avoids.

Instead it adds JSDoc type annotations to code where it adds value, which at the cost of slightly more verbose syntax enables much of the same static analysis and intelli-sense benefits of TypeScript, but without needing any tools to convert it to valid JavaScript, e.g:

/** @param {KeyboardEvent} e */
function validateSafeName(e) {
    if (e.key.match(/[\W]+/g)) {
        e.preventDefault()
        return false
    }
}

TypeScript Language Service

Whilst the code-base doesn't use TypeScript syntax in its code base directly, it still benefits from TypeScript's language services in IDEs for the included libraries from the TypeScript definitions included in /lib/typings, downloaded in postinstall.js after npm install.

Import Maps

Import Maps is a useful browser feature that allows specifying optimal names for modules, that can be used to map package names to the implementation it should use, e.g:

@Html.StaticImportMap(new() {
    ["vue"]                  = "/lib/mjs/vue.mjs",
    ["@servicestack/client"] = "/lib/mjs/servicestack-client.mjs",
    ["@servicestack/vue"]    = "/lib/mjs/servicestack-vue.mjs",
})

Where they can be freely maintained in one place without needing to update any source code references. This allows source code to be able to import from the package name instead of its physical location:

import { ref } from "vue"
import { useClient } from "@servicestack/vue"
import { JsonApiClient, $1, on } from "@servicestack/client"

It's a great solution for specifying using local unminified debug builds during Development, and more optimal CDN hosted production builds when running in Production, alleviating the need to rely on complex build tools to perform this code transformation for us:

@Html.ImportMap(new()
{
    ["vue"]                  = ("/lib/mjs/vue.mjs",                 "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js"),
    ["@servicestack/client"] = ("/lib/mjs/servicestack-client.mjs", "https://unpkg.com/@servicestack/client@2/dist/servicestack-client.min.mjs"),
    ["@servicestack/vue"]    = ("/lib/mjs/servicestack-vue.mjs",    "https://unpkg.com/@servicestack/vue@3/dist/servicestack-vue.min.mjs")
})

Note: Specifying exact versions of each dependency improves initial load times by eliminating latency from redirects.

Or if you don't want your Web App to reference any external dependencies, have the ImportMap reference local minified production builds instead:

@Html.ImportMap(new()
{
    ["vue"]                  = ("/lib/mjs/vue.mjs",                 "/lib/mjs/vue.min.mjs"),
    ["@servicestack/client"] = ("/lib/mjs/servicestack-client.mjs", "/lib/mjs/servicestack-client.min.mjs"),
    ["@servicestack/vue"]    = ("/lib/mjs/servicestack-vue.mjs",    "/lib/mjs/servicestack-vue.min.mjs")
})

Polyfill for Safari

Unfortunately Safari is the last modern browser to support import maps which is only now in Technical Preview. Luckily this feature can be polyfilled with the ES Module Shims:

@if (Context.Request.Headers.UserAgent.Any(x => x.Contains("Safari") && !x.Contains("Chrome")))
{
    <script async src="https://ga.jspm.io/npm:es-module-shims@1.6.3/dist/es-module-shims.js"></script>
}

Fast Component Loading

SPAs are notorious for being slow to load due to needing to download large blobs of JavaScript bundles that it needs to initialize with their JS framework to mount their App component before it starts fetching the data from the server it needs to render its components.

A complex solution to this problem is to server render the initial HTML content then re-render it again on the client after the page loads. A simpler solution is to avoid unnecessary ajax calls by embedding the JSON data the component needs in the page that loads it, which is what /TodoMvc does to load its initial list of todos using the Service Gateway to invoke APIs in process and embed its JSON response with:

<script>todos = @await ApiResultsAsJsonAsync(new QueryTodos())</script>
<script type="module">
import TodoMvc from "/Pages/TodoMvc.mjs"
import { mount } from "/mjs/app.mjs"
mount('#todomvc', TodoMvc, { todos })
</script>

Where ApiResultsAsJsonAsync is a simplified helper that uses the Gateway to call your API and returns its unencoded JSON response:

(await Gateway.ApiAsync(new QueryTodos())).Response?.Results.AsRawJson();

The result of which should render the List of Todos instantly when the page loads since it doesn't need to perform any additional Ajax requests after the component is loaded.

Fast Page Loading

We can get SPA-like page loading performance using htmx's Boosting feature which avoids full page reloads by converting all anchor tags to use Ajax to load page content into the page body, improving perceived performance from needing to reload scripts and CSS in <head>.

This is used in Header.cshtml to boost all main navigation links:

<nav hx-boost="true">
    <ul>
        <li><a href="/Blog">Blog</a></li>
    </ul>
</nav>

htmx has lots of useful real world examples that can be activated with declarative attributes, another useful feature is the class-tools extension to hide elements from appearing until after the page is loaded:

<div id="signin"></div>
<div class="hidden mt-5 flex justify-center" classes="remove hidden:load">
    @Html.SrcPage("SignIn.mjs")
</div>

Which is used to reduce UI yankiness from showing server rendered content before JS components have loaded.

@servicestack/vue Library

@servicestack/vue is our cornerstone library for enabling a highly productive Vue.js development model across our Vue Tailwind Project templates which we'll continue to significantly invest in to unlock even greater productivity benefits in all Vue Tailwind Apps.

In addition to a variety of high-productive components, it also contains a core library of functionality underpinning the Vue Components that most Web Apps should also find useful: