DEV Community

gerard-sans for AWS

Posted on • Updated on • Originally published at Medium

Offline-first made easy with GraphQL, Amplify DataStore and Vue

Create a PWA cloud-enabled offline-first chatroom using Vue

Image for post

In this article, you will create Chatty, a chatroom Progressive Web App (PWA) that stores data on the device while offline, and synchronises in real-time with other devices when online using Amplify DataStore, GraphQL and Amazon DynamoDB. We will cover:

  • Introduction to Offline-first, PWAs and Amplify DataStore
  • Setting up a new project with the Vue CLI
  • Creating a new GraphQL API
  • Amplify DataStore: setup, data models and usage
  • Creating the UI with Vue: the chatroom, sending and deleting messages; and doing real-time with subscriptions
  • Making Chatty a PWA
  • Adding a PWA custom configuration
  • Improving UX while offline
  • Publishing your app via the AWS Amplify Console
  • Installing Chatty in the Desktop and Mobile
  • Cleaning up cloud services

In order to follow this post you will need a basic knowledge of GraphQL. You can learn the basics following this tutorial at graphql.org. We will be referring to GraphQL schema, directives, types, queries, mutations, subscriptions and resolvers.

Please let me know if you have any questions or want to learn more at @gerardsans.

> Final solution and step by step tutorial in GitHub.

Introduction to Offline-first, PWAs and Amplify DataStore

Offline-first is an approach to software development, where an application is built to function, with or without an internet connection. Using this approach, data is stored locally on the user’s device and periodically uploaded and replicated into the cloud. Features include:

  • Offline ready: application works offline. This usually comes with some limitations regarding feature set and device capabilities. Eg: PWAs require an install step where the user needs to be online to download all the necessary application assets.
  • Great user experience: application loads fast and transitions seamlessly from online to offline improving user retention. The user is in control regarding app updates, data synchronisation, data conflict resolution and connectivity. Eg: App shell; user is informed about connectivity changes.
  • Native-like features: application behaves similar to a native app. Eg: PWAs are able to run on multiple operating systems as standalone applications and don’t require the user to interact with the browser.
  • Reliable storage: user can exit the application at any moment without losing data. Application stores user data securely on-device and synchronises with the cloud transparently when possible.

Progressive Web Apps

Progressive Web Apps combine the benefits from native apps, while being able to run on more platforms, leveraging browser technologies. PWA features allow Web apps to close the gap with native applications while creating similar user experiences. PWA features:

  • Works offline via service worker.
  • Great performance via app shell and pre-cached assets.
  • Access to device APIs via Web APIs.
  • Supports push notifications via service worker.
  • Works in Web, Mobile and Desktop via Web app manifest.

The most important requirements for PWA are: security, must be served via HTTPS, the service worker and the Web app manifest. We will incorporate all of these to Chatty.

Amplify DataStore

Amplify DataStore, is an on device persistent repository for interacting with local data, and able to automatically synchronise via GraphQL. Using Amplify DataStore, you can provide a great offline-first experience for your users, while using a simple programming model.

GraphQL knowledge is beneficial when using Amplify DataStore to understand its inner workings but not necessary to use DataStore APIs.

Amplify takes care of all the heavy lifting regarding hosting your data source in the cloud using Amazon DynamoDB; and scaling real-time messaging, synchronising data and running conflict resolution with AWS AppSync and GraphQL.

Amplify DataStore uses your GraphQL schema to generate data models. These will help keep your client code consistent with the type and fields defined in your GraphQL schema; and reduce type checks and data related run-time errors while enforcing GraphQL type safety.

Amplify DataStore behaves differently when in offline or online modes:

  • In offline mode, we use the DataStore API to manage the data on the device via the Storage Engine using GraphQL. The local Data Store being used will vary depending on the user device. This can either be IndexedDB in the browser or SQL Lite in Android and iOS.

Image for post
Amplify DataStore data flow in offline mode

  • In online mode, Amplify DataStore, will synchronise with the cloud using the Sync Engine via GraphQL to automatically synchronise with your GraphQL API. At the query level, this is implemented as an AWS AppSync resolver accessing Amazon DynamoDB, AWS Lambda or Amazon ElasticSearch.

Image for post
Amplify DataStore data flow in online mode

Amplify DataStore supports the following conflict resolution modes during synchronisation:

  • Optimistic concurrency, this mode rejects any incoming changes if there’s a mismatch in the tracking version between the incoming change and the item being changed. A further step on the client is required to resolve the conflict.
  • Auto merge (default), this mode doesn’t require further steps to resolve conflicts. It accommodates mutations on a field-by-field basis as long as scalar types don’t clash. If they do, they are ignored but all other fields are merged, if possible.
  • Custom, this mode uses AWS Lambda to allow more control and complex use cases.

For more details read conflict detection and sync.

Setting up a new project with the Vue CLI

Before moving to the next section, please complete the steps described in “Build your first full-stack serverless app with Vue”. Here you will set up the initial project, familiarise with Amplify CLI and add an authorisation flow so users can register themselves via an automated verification code sent to their email and login.

Creating a new GraphQL API

First, we are going to create the GraphQL API to service Chatty chatroom. To create it, we will use the following command:

amplify add api
Enter fullscreen mode Exit fullscreen mode

Answer the following questions

  • Please select from one of the below mentioned services GraphQL
  • Provide API name: ChattyAPI
  • Choose the default authorization type for the API API key
  • Enter a description for the API key: (empty)
  • After how many days from now the API key should expire (1–365): 7

It’s important that you don’t miss the following steps to set up conflict detection. If you missed it, you can recover with amplify api update

  • Do you want to configure advanced settings for the GraphQL API Yes, I want to make some additional changes.
  • Configure additional auth types? No
  • Configure conflict detection? Yes
  • Select the default resolution strategy Auto Merge
  • Do you want to override default per model settings? No
  • Do you have an annotated GraphQL schema? No
  • Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)
  • Do you want to edit the schema now? Yes

When prompted, replace the default schema with the following:

type Chatty @model {  
  id: ID!  
  user: String!  
  message: String!  
  createdAt: AWSDateTime  
}
Enter fullscreen mode Exit fullscreen mode

By adding @model to the Chatty type we are telling the Amplify CLI to create the resolvers to support queries, mutations and subscriptions in Amazon DynamoDB. Besides the regular CRUD operations, we are also getting some more advanced features like pagination, filtering and real-time synchronisation that we are going to use later.

Pushing your GraphQL API to the cloud

Let’s run the push command to create the GraphQL API:

amplify push
Enter fullscreen mode Exit fullscreen mode
  • Are you sure you want to continue? Yes
  • Do you want to generate code for your newly created GraphQL API Yes
  • Choose the code generation language target javascript
  • Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
  • Do you want to generate/update all possible GraphQL operations — queries, mutations and subscriptions Yes
  • Enter maximum statement depth [increase from default if your schema is deeply nested] 2

Write down your GraphQL endpoint and API KEY.

This is the resulting architecture with our new GraphQL API managed by AWS AppSync and Amazon DynamoDB:

Image for post

Run the command below to access the AWS AppSync console.

amplify console api
Enter fullscreen mode Exit fullscreen mode
  • Please select from one of the below mentioned services GraphQL

Amplify DataStore setup

Run the command below to install the necessary dependencies:

npm install --save @aws-amplify/core @aws-amplify/datastore
Enter fullscreen mode Exit fullscreen mode

Data model generation

Generate the data models to manage our messages for our ChattyAPI.

amplify codegen models
Enter fullscreen mode Exit fullscreen mode

Important: DO NOT forget to regenerate your models every time you introduce a change in your schema.

After running this command, AWS Amplify CLI has generated the necessary data models in a new folder. The files in this folder hold your data model classes and schema.

<amplify-app>  
    |\_ src  
      |\_ models
Enter fullscreen mode Exit fullscreen mode

Creating a message

Now that the GraphQL API and data models are created, we can begin using the DataStore API. The first thing we’ll do is create a new message passing the generated data model to save.

Using data models allow us to be always compliant with the GraphQL schema and prevent errors right in the client.

import { DataStore } from "@aws-amplify/datastore";  
import { Chatty } from "./models";

await DataStore.save(new Chatty({  
  user: "amplify-user",  
  message: "Hi everyone!",  
  createdAt: new Date().toISOString()  
}))
Enter fullscreen mode Exit fullscreen mode

This will create a record locally in your device and synchronise it in the background using the underlying GraphQL API when in online mode.

Querying data

Let’s see how we can query the data using Amplify DataStore. In order to query our data model we will use a query and a predicate to indicate that we want all records.

import { DataStore, Predicates } from "@aws-amplify/datastore";  
import { Chatty } from "./models";

const messages = await DataStore.query(Chatty, Predicates.ALL);
Enter fullscreen mode Exit fullscreen mode

This will return an array of messages that we can display in our UI. Predicates also support filters for common types like Strings, Numbers and Lists.

Find all supported filters at query with Predicates.

Creating the UI with Vue

Now, let’s look at how we can create the UI to create and display messages for Chatty chatroom.

<template>  
  <div v-for="message of sorted" :key="message.id">  
    <div>{{ message.user }} - {{ moment(message.createdAt).format('YYYY-MM-DD HH:mm:ss')}})</div>  
    <div>{{ message.message }}</div>  
  </div>  
</template>  
<script>  
import { DataStore, Predicates } from "@aws-amplify/datastore";  
import { Chatty } from "./models";  
import moment from "moment";

export default {  
  name: 'app',  
  data() {  
    return {  
      user: {},  
      messages: [],  
    }  
  },  
  computed: {  
    sorted() {  
      return [...this.messages].sort((a, b) => -a.createdAt.localeCompare(b.createdAt));  
    }  
  },  
  created() {  
    // authentication state managament  
    onAuthUIStateChange((state, user) => {  
      // set current user and load data after login  
      switch (state) {  
        case AuthState.SignedIn: {  
          this.user = user;  
          this.loadMessages();  
          break;  
        }  
      }  
    });  
  },  
  methods: {  
    moment: () => moment(),  
    loadMessages() {  
      DataStore.query(Chatty, Predicates.ALL).then(messages => {  
        this.messages = messages;  
      });  
    },  
  }  
}  
</script>
Enter fullscreen mode Exit fullscreen mode

After the user signs in, we call loadMessages. This fetches all the messages in our local store. We are sorting the list with a computed method sorted as we want our messages to show the most recent at the top. We are also using moment to format our time variables.

Creating a message

Now, let’s look at how we create new messages.

<template>  
  <form v-on:submit.prevent>  
    <input v-model="form.message" placeholder="Enter your message..." />  
    <button @click="sendMessage">Send</button>  
  </form>  
</template>  
<script>  
export default {  
  data() {  
    return {  
      form: {},  
    };  
  },   
  methods: {  
    sendMessage() {  
      const { message } = this.form  
      if (!message) return;  
      DataStore.save(new Chatty({  
        user: this.user.username,  
        message: message,  
        createdAt: new Date().toISOString()  
      })).then(() => {  
        this.form = { message: '' };  
        this.loadMessages();  
      }).catch(e => {  
        console.log('error creating message...', e);  
      });  
    },  
  }  
}  
</script>
Enter fullscreen mode Exit fullscreen mode

We use a form to read the message from the user and call sendMessage with save passing the user and current date along with the message. Once the message has been created we reset the input field and reload all messages. Note how we use Amplify DataStore as our source of truth in this implementation.

Deleting all messages

One of the main advantages of working using Amplify DataStore is being able to run a series of GraphQL mutations without having to use a series of individual operations. See below how we can use delete together with a predicate to remove all messages.

DataStore.delete(Chatty, Predicates.ALL).then(() => {  
  console.log('messages deleted!');  
});
Enter fullscreen mode Exit fullscreen mode

When using Amplify DataStore records are never removed, but marked for removal. Records will then be automatically removed as defined in their TTL attribute. At the time of writing, this is set to 30 days.

Real-time with GraphQL subscriptions

Next, let’s see how we can create a GraphQL subscription. To do so, we will listen for changes of data in our API, and update the state whenever a new piece of data comes through. When the component is destroyed, we will unsubscribe to avoid memory leaks.

As we are using Amplify DataStore, as our source of truth, we don’t need another copy of it at the app level so we just need to load the latest state with loadMessages.

<script>  
export default {  
  data() {  
    return {  
      subscription: undefined;  
    };  
  },  
  created() {  
    //Subscribe to changes  
    this.subscription = DataStore.observe(Chatty).subscribe(msg => {  
      console.log(msg.model, msg.opType, msg.element);  
      this.loadMessages();  
    });  
  },   
  destroyed() {  
    if (!this.subscription) return;  
    this.subscription.unsubscribe();  
  },  
}  
</script>
Enter fullscreen mode Exit fullscreen mode

At this point, the chatroom is fully working including authentication flow and data synchronisation in real-time.

Image for post
Real-time synchronisation using Chrome and Firefox clients side-by-side.

On the other hand, we haven’t made much progress in making it work offline or having native-like features. In order to tick these boxes, we are going to make our app a PWA.

Making Chatty a PWA

The PWA CLI plugin can help us making Chatty a PWA by running a single command:

vue add @vue/pwa
Enter fullscreen mode Exit fullscreen mode

This will make few changes to the project and add a few new assets necessary to run the app as a PWA. The most important change happens in src/main.js where we are now registering a service worker that will allow Chatty to work offline.

Using the default settings, the service worker is configured to pre-cache all the assets resulting from the build (js, css and public folder). Using this setup, the browser can access the cache to load the app while offline. See the diagram below:

Image for post
Service worker serving assets from the cache while offline

Learn more about service workers at using service workers.

Run the build command to see changes:

yarn build
Enter fullscreen mode Exit fullscreen mode

In your dist folder you should find the following:

└── dist  
  ├── css  
  │   └── app.<version>.css  
  ├── img/icons  
  │   ├── android-chrome-<size>.png  
  │   └── ...  
  ├── js  
  │   ├── app.<version>.png  
  │   └── ...  
  ├── favicon.ico  
  ├── index.html  
  ├── manifest.json  
  ├── precache-manifest.<version>.json  
  ├── robots.txt  
  └── service-worker.js
Enter fullscreen mode Exit fullscreen mode

The index.html has been modified to include the Web app manifest manifest.json along with some meta tags and entries to support different devices and platforms. You can further customise these settings. The most important ismanifest.json/display set to standalone. This setting will give the app a look and feel like a native application for Desktop and Mobile. Learn about all the options available in Web app manifest.

Another important file is service-worker.js. This contains the implementation for the service worker which determines the behaviour of the app while offline. As part of it, precache-manifest.<version>.json includes all files to be installed and pre-cached as part of the app. This will make the app load almost instantly as it won’t use the network. These files were created by the PWA CLI plugin and include all the assets from your build.

Test the app running the following commands on a new terminal window:

cd dist  
python -m SimpleHTTPServer 8887  // open localhost:8887
Enter fullscreen mode Exit fullscreen mode

Remember that in order to test the app you need to install the service worker first. This requires at least loading the app once.

To test the app while offline you can use the Developer Tools or switch off your internet connection. See below a screen capture after refreshing the page while offline:

Image for post
Chatty app while offline.

As you can see in the screenshot above using Chrome, in the Network tab, the files are served by the service worker while offline (in green). Unfortunately the default setup won’t include any custom assets like our SVG logo in the header (in red) or external fonts.

Adding a PWA custom configuration

Let’s fix the issue with our logo by using a custom configuration. Create vue.config.js with the following content at the root of your project:

// vue.config.js  
const manifest = require('./public/manifest.json')module.exports = {  
  pwa: {  
    name: manifest.short_name,  
    themeColor: manifest.theme_color,  
    msTileColor: manifest.background_color,  
    appleMobileWebAppCapable: 'yes',  
    appleMobileWebAppStatusBarStyle: 'black',  
    workboxPluginMode: 'InjectManifest',  
    workboxOptions: {  
      swSrc: 'src/service-worker.js',  
    }  
  }  
}
Enter fullscreen mode Exit fullscreen mode

This file controls the PWA CLI plugin and deal with settings in the Web app manifest and UX for different devices.

The custom configuration uses workbox in injectManifest mode and points to the service worker implementation we want to use. Find all configuration options at the official website. Now create src/service-worker.js with the following code

// src/service-worker.js  
workbox.core.setCacheNameDetails({ prefix: 'amplify-datastore' })

workbox.core.skipWaiting()  
workbox.core.clientsClaim()

const cacheFiles = [{  
 "revision": "e653ab4d124bf16b5232",  
 "url": "[https://aws-amplify.github.io/img/amplify.svg](https://aws-amplify.github.io/img/amplify.svg)"  
}]

self.__precacheManifest = cacheFiles.concat(self.__precacheManifest || [])  
workbox.precaching.precacheAndRoute(self.__precacheManifest, {})
Enter fullscreen mode Exit fullscreen mode

For our service worker, we are setting a prefix for our cache to easily find our files while using the Developer Tools. We are also setting the behaviour for new service worker versions. These will be applied immediately replacing any existing ones with skipWaiting and clientsClaim as explained here. To fix our SVG issue, we are adding the url to the SVG file, to the pre-cached files defined in the Web app manifest.

Improving UX while offline

We are almost done. Our app can survive offline reloads, can store messages while offline and can synchronise when back online thanks to Amplify DataStore.

The only remaining issue now is improving the user experience as the user doesn’t know when the app is operating online or offline. This is not a great UX, as sometimes the user might inadvertently go offline and be confused as for not receiving any feedback or explanation from the app. We can fix this by using the code below:

// <div v-if="offline">You are offline.</div>  
// <div v-bind:class="{ offline: offline }">

// App.vue  
import { Hub } from 'aws-amplify';

export default {  
  data() {  
    return { offline: undefined };  
  },  
  created() {  
    this.listener = Hub.listen('datastore', {payload:{event}} => {  
      if (event === 'networkStatus') {  
        this.offline = !data.active;  
      }  
    })  
  }  
}
Enter fullscreen mode Exit fullscreen mode

We are using the Amplify Hub to detect network changes and use it to set the offline flag. Once in place, we can use it to display messages or style the UI accordingly as shown at the beginning of the code above. We are now ready to publish Chatty to the cloud and test it using different devices.

Image for post
Chatty PWA informing the user of changes in the network status.

Publishing your app via the AWS Amplify Console

The first thing you need to do is create a new repo for this project. Once you’ve created the repo, copy the URL for the project to the clipboard and initialise git in your local project:

git init  
git remote add origin [repo@repoofyourchoice.com](mailto:repo@repoofyourchoice.com):username/project-name.git  
git add .git commit -m 'initial commit'git push origin master
Enter fullscreen mode Exit fullscreen mode

Next visit the AWS Amplify Console in your AWS account. Click Get Started to create a new deployment. Next, authorise your repository provider as the repository service. Next, choose the new repository and branch for the project you just created and click Next. In the next screen, create a new role and use this role to allow the AWS Amplify Console to deploy these resources and click Next. Finally, click Save and Deploy to deploy your application!

Image for post
AWS Amplify Console deployment steps.

This will generate a public url that uses HTTPS, which is a requirement for PWAs. Congratulations! You can now test it in your browser, Desktop and Mobile.

Installing the Chatty app in the Desktop and Mobile

Due to the inclusion of the Web app manifest in our index.html page, you can now install the Chatty app in the Desktop and Mobile devices in Windows, Mac and Linux. The way it works, changes slightly depending the device and operating system you are using. Try it out following the steps in Add to Home screen.

Image for post
Use add to Home screen to install in Desktop and Mobile.

Cleaning up cloud services

If at any time, you would like to delete a service from your project and your AWS Account, you can do this by running:

amplify delete
Enter fullscreen mode Exit fullscreen mode

Conclusion

Congratulations! You successfully built your first offline-first app using Vue and Amplify. You created a chatroom PWA that stores data on the device while offline and when online synchronises in real-time with other devices using Amplify DataStore, AWS AppSync, GraphQL and Amazon DynamoDB. Thanks for following this tutorial.

If you prefer, you can follow the instructions in this video to build the chatroom app.

Thanks for reading!

Have you got any questions regarding this tutorial or AWS Amplify? Feel free to reach out to me anytime at @gerardsans.

Image for post

My Name is Gerard Sans. I am a Developer Advocate at AWS Mobile working with AWS Amplify and AWS AppSync teams.
GraphQL is an open source data query and manipulation language for APIs, and a runtime for fulfilling queries with existing data.

Top comments (1)

Collapse
 
alsmith808 profile image
Alan Smith

Hey Gerard,

I'm using DataStore in a project I'm working on. I'm finding it the sync part quite tricky with DataStore, when I start off with something small it works but as soon as my project gets bigger the synching part seems to be problematic, I've noticed mnay seem to have the same problem, do you have any tips for using DataStore that might not be obvious t first, thanks!

Alan