Engineering
April 17, 2024
April 17, 2024
(updated)

Engineering Notes: Integrating with Vue for Local-First SPAs

Christiaan Landman

The JavaScript SPA ecosystem

In the ever-evolving JavaScript (Web) ecosystem, a select group of Single Page Application (SPA) frameworks claim the majority of developer mindshare and shape the trajectory of modern web development. Meanwhile, emerging contenders aim to challenge existing paradigms or introduce unique features, often focused on enhancing performance or developer experience. Some noteworthy ones include:

  1. React: a library (not a framework) developed by Facebook. It utilizes a component-based architecture, enabling developers to create reusable UI elements.
  2. Vue: a framework that provides a reactive and component-based structure similar to React. It also allows developers to incrementally adopt its features into existing JavaScript projects.
  3. Angular: a framework maintained by Google.
  4. Svelte: a novel approach to building user interfaces by shifting much of the work to compile time. Instead of shipping a framework to the browser, Svelte compiles components into highly optimized JavaScript during the build process.
  5. Solid: a framework that feels similar to React when you compare its syntax to Functional Components and Hooks from React. Component functions are called only once at component construction and reactivity is facilitated by signals.
JavaScript web frameworks.

Two things that are common across these frameworks are that they are all manipulating HTML and have some sort of reactivity model (change HTML when something happens/changes). It’s common in building modern applications with SPAs to make sets of elements and units of logic reusable. This is often done by breaking code into components (leaning to separation by presentation) or somehow breaking logic out (reactivity logic included) into an importable module. Most SPA frameworks support some sort of component structure and something akin to React Hooks for sharing logic.

Additionally, there are meta frameworks that provide features and capabilities beyond what an SPA framework does out of the box. This can range from full-stack functionality (front to back) and server-side rendering (SSR) to other advanced features like data caching, routing, authentication, and authorization. Meta-frameworks tend to be built on top of an existing SPA framework. Some popular meta frameworks include:

  1. Next.js: a React framework. 
  2. Nuxt.js: a Vue.js framework.
  3. SvelteKit: a Svelte framework. 
  4. SolidStart: a framework for Solid that is currently in beta.
  5. Astro: a framework for content-driven websites. Astro is UI-agnostic, meaning you can bring your own framework. React, Vue, Angular, Svelte, and Solid are all officially supported in Astro.
JavaScript web meta frameworks.

Reducing friction of using PowerSync for SPAs

Of the above-mentioned SPA frameworks, PowerSync previously only had a wrapper package for React. It uses React Hooks around the PS common SDK package to support reactivity/live query integration for PowerSync functionality — making it easy for developers using React in their project to leverage PowerSync. This didn’t mean that non-React based web projects could not use PowerSync, but that the barrier to entry was a bit higher than if they were to use React.

Today, we announced the alpha release of a Vue Composables package for PowerSync. In this post, we will provide some background on its development.

Where we started: Porting React implementation to Vue

For adding the Vue reactivity wrapper, our first stab was to take the React wrapper and convert the React-specific code directly. The plan was to implement the wrapper code as composables which work similar to Hooks. Consider a quick and dirty port of one of the React Hooks:

React: [.inline-code-snippet]usePowerSyncWatchedQuery.ts[.inline-code-snippet]

import { SQLWatchOptions } from '@journeyapps/powersync-sdk-common';
import React from 'react';
import { usePowerSync } from './PowerSyncContext';
/**
 * A hook to access the results of a watched query.
 */
export const usePowerSyncWatchedQuery = <T = any>(
  sqlStatement: string,
  parameters: any[] = [],
  options: Omit<SQLWatchOptions, 'signal'> = {}
): T[] => {
  const powerSync = usePowerSync();
  if (!powerSync) {
    return [];
  }
  const memoizedParams = React.useMemo(() => parameters, [...parameters]);
  const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]);
  const [data, setData] = React.useState<T[]>([]);
  const abortController = React.useRef(new AbortController());
  React.useEffect(() => {
    // Abort any previous watches
    abortController.current?.abort();
    abortController.current = new AbortController();
    (async () => {
      for await (const result of powerSync.watch(sqlStatement, parameters, {
        ...options,
        signal: abortController.current.signal
      })) {
        setData(result.rows?._array ?? []);
      }
    })();
    return () => {
      abortController.current?.abort();
    };
  }, [powerSync, sqlStatement, memoizedParams, memoizedOptions]);
  return data;
};

Vue: [.inline-code-snippet]usePowerSyncWatchedQuery.ts[.inline-code-snippet]

// Note that with Vue's reactivity model - the 3 parameters for the composable aren't reactive and will need to be changed/wrapped to accommodate that.

import { SQLWatchOptions } from '@journeyapps/powersync-sdk-common';
import { usePowerSync } from './PowerSyncContext';
import { onBeforeUnmount, ref, watchEffect } from 'vue';

/**
 * A composable to access the results of a watched query.
 */
export const usePowerSyncWatchedQuery = <T = any>(
  sqlStatement: string,
  parameters: any[] = [],
  options: Omit<SQLWatchOptions, 'signal'> = {}
) => {
  const powerSync = usePowerSync();
  const data = ref([]);
  if (!powerSync) {
    return data.value;
  }
  let abortController = new AbortController();
  watchEffect(() => {
    // Abort any previous watches
    abortController.abort();
    abortController = new AbortController();
    (async () => {
      try {
        for await (const result of powerSync.value.watch(sqlStatement, parameters, {
          ...options,
          signal: abortController.signal
        })) {
          data.value = result.rows?._array ?? [];
        }
      } catch (error) {
        console.error('Error while watching query:', error);
      }
    })();
    onBeforeUnmount(() => {
      abortController.abort();
    });
  });
  return data;
};

We also identified the possibility to improve on the signatures of the [.inline-code-snippet]usePowerSyncQuery[.inline-code-snippet] and [.inline-code-snippet]usePowerSyncWatchedQuery[.inline-code-snippet] composables to return a reactive error and loading state which developers can hook onto. 

// An example drawn from Nuxt’s documentation:
const { data, error, execute, refresh } = await useFetch('/api/users')

// Could allow us to enrich the dev experience - just illustrative examples
const { data: customers, loading, error } = usePowerSyncWatchedQuery('SELECT id, name FROM customers');

// Current React usage
const customers = usePowerSyncWatchedQuery('SELECT id, name FROM customers');

After the reactivity was proven to work with a demo project (a Vue demo project derived from our react-supabase-todolist example), the next step was to include the Vue wrapper and demo project in the powersync-js repo. 

Unknowns at the time of initial design

  1. How to map the React context/provider mechanism to Vue such that the same configuration is possible. With the React wrapper and React example project, context/providers are used to configure children of a top-level component such that all children are configured with a certain configuration.

    Vuetify, Pinia, and Vue Router (well-known Vue libraries) use Vue’s plugin mechanism which allows a library to have app-wide configuration installed on the Vue instance during instantiation — often propagating the configuration with provide/inject.
  1. For the quick port above, parameters of the composable weren’t reactive. To do that in Vue you would need to wrap them with some help from Vue. For context, a Vue composable/component only executes once, whereas a React function/component executes multiple times — which is where we saw that parameter reactivity breaking in the Vue version.

    A first attempt was to make the composables take in both primitives and wrapped versions of the parameters. See MaybeRef<T>.

Update post-POC: Alpha implementation for Vue composables

This is what we came up with after some experimentation: The parameters to the composable can either be normal JavaScript variables or refs, which on change will cause the query to refresh automatically. This is using the newly added [.inline-code-snippet]watch[.inline-code-snippet] with callback approach instead of [.inline-code-snippet]AsyncIterator[.inline-code-snippet]. Note that the implementation below might differ a bit from the released version.

[.inline-code-snippet]usePowerSyncWatchedQuery.ts[.inline-code-snippet]

import { SQLWatchOptions } from '@journeyapps/powersync-sdk-common';
import { MaybeRef, Ref, ref, toValue, watchEffect } from 'vue';
import { usePowerSync } from './powerSync';

export type WatchedQueryResult<T> = {
  data: Ref<T[]>;
  /**
   * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs.
   */
  loading: Ref<boolean>;
  error: Ref<Error>;
};

/**
 * A composable to access the results of a watched query.
 */
export const usePowerSyncWatchedQuery = <T = any>(
  sqlStatement: MaybeRef<string>,
  parameters: MaybeRef<any[]> = [],
  options: Omit<SQLWatchOptions, 'signal'> = {}
): WatchedQueryResult<T> => {
  const data = ref([]);
  const error = ref<Error>(undefined);
  const loading = ref(true);

  const powerSync = usePowerSync();

  let abortController = new AbortController();
  watchEffect((onCleanup) => {
    // Abort any previous watches when the effect triggers again, or when the component is unmounted
    onCleanup(() => abortController.abort());
    abortController = new AbortController();

    if (!powerSync) {
      error.value = new Error('PowerSync not configured.');
      return;
    }

    powerSync.value.watch(
      toValue(sqlStatement),
      toValue(parameters),
      {
        onResult: (result) => {
          loading.value = false;
          data.value = result.rows?._array ?? [];
          error.value = undefined;
        },
        onError: (e: Error) => {
          loading.value = false;
          data.value = [];

          const wrappedError = new Error('PowerSync failed to fetch data: ' + e.message);
          wrappedError.cause = e; // Include the original error as the cause
          error.value = wrappedError;
        }
      },
      {
        ...options,
        signal: abortController.signal
      }
    );
  });

  return { data, loading, error };
};

See below for an example of this new composable in action. The query will refresh as the user changes the SQL in the input field, and any errors generated while typing will be shown.

[.inline-code-snippet]TodoListDisplayWatchedQuery.vue[.inline-code-snippet]

<script setup lang="ts">
import { usePowerSync, usePowerSyncWatchedQuery } from '@journeyapps/powersync-vue';
import { ref } from 'vue';

const query = ref('SELECT * from lists');
const { data: list, loading, error} = usePowerSyncWatchedQuery(query);

const powersync = usePowerSync();
const addList = () => {
    powersync.value.execute('INSERT INTO lists (id, name) VALUES (?, ?)', [Math.round(Math.random() * 1000), 'list name']);
}
</script>

<template>
    <input v-model="query" />
    
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
        <li v-for="l in list" :key="l.id">{{ l.name }}</li>
    </ul>
    <button @click="addList">Add list</button>
</template>

Bonus: for those unfamiliar with all the popular JavaScript options

Careful, this guy has opinions.