Scaffolding an App with Vue 3, Nuxt and TypeScript

Daniel Schulz - Mar 28 '21 - - Dev Community

For most of my projects I use my own, simple setup that takes a step back from large, JavaScript-based web apps. One limitation that comes with it is sharing state between multiple screens. My setup doesn't do soft navigations by design, so it has to rely on localStorage, sessionStorage, and indexedDB for persistent states. That becomes cumbersome very quickly and I acknowledge that some projects absolutely benefit from reactivity and the automagic mechanisms, that the likes of Vue and React bring with them.

Setting up such a project can be tricky, especially if you don't know the entire ecosystem of your chosen framework and simply want to get on with a little side project. Here's how and why mine a wired together - from choosing a tech stack to implementing a persistent store.

Vue, not React

I got into Vue before taking a look at React and I think that alone plays the largest role in that decision. I find Vue more intuitive to use. Vue's Single File Components easily wrap up a component in a concise, human-readable way. Also, there's the Composition API with its setup() and reactive() mechanics, which are an absolute joy to use. However, using Vue (or React or Angular in that case)brings one huge drawback: It's naturally Client-Side Rendered - meaning the actual document you serve to the browser is nothing but a link to a huge JS file, without which your site simply won't display anything at all. This has all sorts of bad implications, from a UX point of view to Performance.

I'd like to pre-render my Vue project on the server and push that to the browser. The tool to do that would be Nuxt.

Set up Nuxt

Nuxt is a framework that builds on top of Vue. One of its key features is Server Side Rendering. It creates fully populated documents from your Vue components and serves those. Those documents look like the finished website but don't behave like it yet. All the logic is still packaged into JS bundles and sent to the browser separately. As soon as the JS initializes, it adds the usual Vue functionality to the site. This mechanic is called Hydration and helps with Vue's performance implications.

Using Nuxt is likely a key decision at the very start of the project because it's so fundamental to the structure and build process. Switching to Nuxt later in the development process probably involves some big rewrites.

Installing Nuxt is described very well in their own documentation. I usually go the create-nuxt-app way, because that takes most of the setup process out of my hands and provides well-working presets for a PWA.

Use Vue 3 with Nuxt

At the time of writing, Nuxt (2.15) still uses Vue 2 by default, but it provides a node package that exposes the Composition API:

yarn add @nuxtjs/composition-api
Enter fullscreen mode Exit fullscreen mode

In order to use Vue's new features, you don't import its components from vue, but from @nuxtjs/composition-api instead:

import {
    defineComponent,
    inject,
    computed,
    onMounted,
    ref,
} from "@nuxtjs/composition-api";
Enter fullscreen mode Exit fullscreen mode

TypeScript

Using TypeScript is optional. You can skip this step entirely if you like. Not every project absolutely needs TypeScript.

I found that using it gives me some confidence in my code because it forces me to think my data structures through before implementing them. In plain JavaScript, I used to just code it. I may have been faster in prototyping, but maintaining my progress further down the development process became ever more painful and slow. Whenever I needed to extend a data structure, it involved a lot of refactoring. With TypeScript, I tend to write cleaner structures, to begin with. I find Refactoring easier because my types serve as documentation for themselves. I'll use TypeScript in my code snippets in this article - beware when copypasting.

However, it also provides some arbitrary hoops to jump through and increases the complexity of the project. Simply hacking your Vue components together as with Vanilla JS won't work. Here are some things to look out for:

Specify the language

You need to specify <script lang="ts"> in your component, to actually use TypeScript in Vue.

Typing props

Vue already uses type primitives in props. Those translate into TypeScript seamlessly.

props: {
    id: {
        type: Number,
        required: true
    },
    name: String
},
Enter fullscreen mode Exit fullscreen mode

But TypeScript is capable of a lot more than that. In order to use Interfaces as prop types, you can cast an Object primitive as an Interface like that:

interface Person {
    id: number;
    firstName: string;
    lastName: string;
    registered: boolean;
}

...

props: {
    person: {
        type: Object as () => Person
    }
}
Enter fullscreen mode Exit fullscreen mode

Now your component will only accept correctly typed Person objects in its properties.

Typing Refs

Refs are Vue's most simple technique to make things reactive.

const foo = ref<string | number>("foo");
Enter fullscreen mode Exit fullscreen mode

They're also unique in their ability to work with DOM elements. But whenever the DOM and strict types meet, chaos ensues. DOM Refs are declared with ref(null). The ref value is only filled when the component renders. Before that, the value is (as we declared) null. After it renders, it is populated with the HTML element we set it to in the component template. But we still don't know what kind of an HTML element it is in the script section, so TypeScript won't let us use its API yet. To fix that, let's type the ref when we set it:

<textarea ref="userInputEl"></textarea>
Enter fullscreen mode Exit fullscreen mode
const userInputEl = ref<HTMLTextareaElement | null>(null);
userInputEl.value?.setSelectionRange(0, 10);
Enter fullscreen mode Exit fullscreen mode

Build your own store

Mario Brendel wrote a really nice article on how we might not need VueX in Vue 3 anymore. It boils down to using the Composition API to provide a reactive object across multiple components.

A diagram describing how the app provides a store reactive object that gets injected into athe app's components.

He shares some code snippets after which I model my stores on as well. The nice thing about that technique is that you don't have to deal with a behemoth like VueX or Redux. Instead, you build your own store exactly to your needs. A very simple implementation could look like this:

import { Person, persons } from "@/data/persons.json";
import { Cat, cats } from "@/data/cats.json";
import { Dog, dogs } from "@/data/dogs.json";

interface StoreData {
    persons: Person[];
    cats: Cat[];
    dogs: Dog[];
}

export class Store {
    protected state: StoreData;

    constructor(readonly storeName: string) {
        const data = this.data();
        this.state = reactive(data);
    }

    protected data() {
        return {
            persons,
            cats,
            dogs,
        };
    }

    public getPersons(): Person[] {
        return this.state.persons;
    }

    public getCats(): Cat[] {
        return this.state.persons;
    }

    public getDogs(): Dog[] {
        return this.state.persons;
    }

    //...and all the other store logic
}

export const store = new Store("DataStore");
Enter fullscreen mode Exit fullscreen mode

This will give me a class that can return a slice from a reactive object. That's basically all I ever wanted out of Vue. In order to use it across the whole app, we can use the Composition API's provide/inject methods: A base component high up at the root of the component tree provides the store and all its children can inject it.

// the app's base component
import { defineComponent } from "@nuxtjs/composition-api";
import { store } from "@/store/store";

export default defineComponent({
    provide: {
        store,
    },
});
Enter fullscreen mode Exit fullscreen mode
// a component that needs access to the store
import { defineComponent, inject } from '@nuxtjs/composition-api';
import { Store } from '@/store/store';

export default defineComponent({
    setup() {
        const store = inject('store') as Store;
        const persons = store.getPersons();
    }
}
Enter fullscreen mode Exit fullscreen mode

Persisting with localStorage

Now, the store is constructed whenever the App loads. That's fine for soft navigations, but a hard reload or following a hard link would clear it. That's fine if your store only keeps information like if a drawer menu should be opened or closed, or if it only loads static data from a JSON file anyway. But if you store a large user input like a filled form, it'd be very annoying to let that clear just because the user reloads the page.

localStorage (or sessionStorage or indexedDb, depending on your needs) comes to save the day! When the store initializes, we check if the browser has already some data cached and use that. If not, the store initializes with empty states.

import { FormData } from "@/data/formData.js";

interface StoreData {
    formData: FormData;
}

export class Store {
    protected state: StoreData;

    constructor(readonly storeName: string) {
        const data = this.data();
        this.state = reactive(data);
    }

    protected data() {
        const localStorage = process.browser
            ? window.localStorage
            : {
                    getItem(): string {
                        return "";
                    },
              };

        return {
            formData: localStorage.getItem("formData") || new FormData(),
        };
    }

    protected persist(key: "formData"): void {
        if (!process.browser) {
            return;
        }
        localStorage.setItem(key, String(this.state[key]));
    }

    public getFormData(): FormData[] {
        return this.state.formData;
    }

    public setFormData(payload: FormData): void {
        this.state.formData = payload;
        this.persist("formData");
    }

    //...and all the other store logic
}

export const store = new Store("FormStore");
Enter fullscreen mode Exit fullscreen mode

This example uses a persist() method on every setter that updates the localStorage with current data. Using a browser API like localStorage in Nuxt can be tricky because our App might be server-side rendered. That way the store would be initialized, while we're still in the node environment of the server. Client-side storages are not available here (and even if they were, the server would have no way of knowing its content). That's why we check for process.browser. It returns true if we're client-side and have access to the browser APIs. While on the server, we mock a storage API and its getItem() method to only return an empty string. It's fortunate that localStorage only stores strings anyway, it keeps our mock simple.

Looking back

Each of those steps adds complexity to the project, which is something I watch very critically. Whenever I spin up a side project, I tend to work on it for a few days and then leave it for months until I don't know anymore how it works. Keeping the stack complexity down is important to me.

That's why every step of my stack is optional. Building just a little Proof of Concept and TypeScript is standing in your way? Leave it out. Your App is too small to justify a store? Don't implement one then. Most importantly though: Don't need Vue? Just ignore this article altogether and use a more fitting stack.

. . . . . . . . . . . . . . . . . . . . . . . .