Skip to content

Instantly share code, notes, and snippets.

@dabit3
Last active December 10, 2023 03:52
Show Gist options
  • Save dabit3/7b5bddfe5e52e1845fa2a03afe565d78 to your computer and use it in GitHub Desktop.
Save dabit3/7b5bddfe5e52e1845fa2a03afe565d78 to your computer and use it in GitHub Desktop.
Building offline apps with Amplify DataStore

Building offline apps with Amplify DataStore

To view and deploy the app covered in this chapter, check out this repo.

So far in this book we've worked with both REST APIs and GraphQL APIs. When working with the GraphQL APIs so far, we've used the API class to directly call mutations and queries against the API.

Amplify also supports another type of API for interacting with AppSync, Amplify DataStore. DataStore has a different approach than a traditional GraphQL API.

Instead of interacting with the GraphQL API itself using queries and mutations, DataStore introduces a client-side SDK that persists the data locally using the local storage engine of the platform you are working with (i.e. IndexDB for web, SQLLite for native iOS and Android). DataStore then automatically syncs the local data to the GraphQL backend for you as updates are made both locally and remotely.

Using the DataStore SDK, you then only have to perform the operations like save, update, and delete, writing directly to DataStore itself. DataStore handles everything else for you, syncing your data to the cloud when you have an internet connection, and when not connected to the internet, will queue it for the next time you are connected to the internet.

DataStore also handles conflict detection and resolution for you with one of built-in 3 conflict resolution strategies:

  1. AutoMerge - GraphQL type information on an object is inspected at runtime to perform merge operations
  2. Optimistic Concurrency - the latest written item to your database will be used with a version check against the incoming record
  3. Custom - Use a Lambda function and apply any custom business logic you wish to the process when merging or rejecting updates

About Amplify DataStore

Amplify DataStore is a combination of a few things:

  1. AppSync GraphQL API
  2. A local storage repository and syncing engine that also persists data offline
  3. Client-side SDK for interacting with the local storage repository
  4. Special sync-enabled GraphQL resolvers (generated by the Amplify CLI) that enable sophisticated conflict detection and conflict resolution on the server

Amplify DataStore Overview

When getting started with DataStore, you still create the API as we have done in past chapters. The main difference is, when creating the API, you will enable conflict detection in the advanced settings of the CLI flow.

From there, to enable DataStore on the client, we need to create models for DataStore to use to interact with the storage repository. This can easily be done by just using the GraphQL schema you already have and running amplify codegen models from the CLI.

Now, you are all set up and can begin interacting with DataStore.

Amplify DataStore Operations

To interact with the Store, you first import the DataStore API from Amplify and the Model you'd like to use. From there, you can perform actions against the store.

Let's have a look at the different operations that are available.

Importing the model and DataStore API

import { DataStore } from '@aws-amplify/datastore'
import { Message} from './models'

Saving data with Amplify DataStore

await DataStore.save(
  new Message({
    title: 'Hello World',
    sender: 'Chris'
  })
))

Reading data with Amplify DataStore

const posts = await DataStore.query(Post)

Deleting data with Amplify DataStore

const message = await DataStore.query(Message, '1234567')
DataStore.delete(message)

Updating data with Amplify DataStore

const message = await DataStore.query(Message, '1234567')

await DataStore.save(
	Post.copyOf(message, updated => {
		updated.title = 'My new title'
	})
)

Observing / subscribing to changes in data for real-time functionality

const subscription = DataStore.observe(Message).subscribe(msg => {
  console.log(message.model, message.opType, message.element)
});

DataStore Predicates

You can apply predicate filters against the DataStore using the fields defined on your GraphQL type along with the following conditions supported by DynamoDB:

Strings: eq | ne | le | lt | ge | gt | contains | notContains | beginsWith | between

Numbers: eq | ne | le | lt | ge | gt | between

Lists: contains | notContains

For example if you wanted a list of all messages that have a title that includes "Hello":

const messages = await DataStore.query(Message, m => m.title('contains', 'Hello'))

You can also chain multiple predicates into a single operation:

const message = await DataStore
  .query(Message, m => m.title('contains', 'Hello').sender('eq', 'Chris'))

Using predicates enables you to have many ways to retrieve different selection sets from your local data. Instead of retrieving the entire collection and filtering on the client, you are able to query from the store exactly the data that you need.

Building an offline and real-time app with Amplify DataStore

The app that we will build is a real-time and offline-first message board.

Users of the app can create a new message and all other users will receive the message in real-time.

If a user goes offline, they will continue to be able to create messages and once they are online the messages will be synced with the back end, and all other messages created by other users will also be fetched and synced locally.

Our app will perform three types of operations against the DataStore API:

save - Creating a new item in the DataStore, performs a GraphQL mutation behind the scenes.

query - Reading from the DataStore, returns a list (array), performs a GraphQL query behind the scenes

observe - Listening for changes (create, update, delete) in data, performs a GraphQL subscription behind the scenes.

Let's get started!

Creating the base project

To get started, we will create a new React project, initialize an Amplify app, and install the dependencies.

The first thing we will do is create the React project:

$ npx create-react-app rtmessageboard

$ cd rtmessageboard

Next, we will install the local dependencies.

Amplify supports both a full installation of Amplify, or scoped (modular) installations for specific APIs. Scoped packages reduce the bundle size since we are only installing the code that we are using, and are something we have not yet used in this book. Since we are only using the DataStore API, we can install the scoped DataStore package.

We will also install Ant Design (antd) for styling, React Color (react-color) for a nice and easy to use color picker, and the scoped dependency for Amplify Core in order to still configure the Amplify app with our aws-exports.js configuration.

$ npm install @aws-amplify/core @aws-amplify/datastore antd react-color

Now, initialize a new Amplify project:

$ amplify init

# Follow the steps to give the project a name, environment a name, and set the default text editor.
# Accept defaults for everything else and choose your AWS Profile.

Creating the API

Next, we will create the AppSync GraphQL API:

$ amplify add API

? Please select from one of the below mentioned services: GraphQL
? Provide API name: rtmessageboard
? Choose the default authorization type for the API: API key
? Enter a description for the API key: public
? After how many days from now the API key should expire (1-365): 7 (or your preferred expiration)
? Do you want to configure advanced settings for the GraphQL API: Yes
? Configure additional auth types: N
? Configure conflict detection: Y
? Select the default resolution strategy: Auto Merge
? Do you have an annotated GraphQL schema: N
? Do you want a guided schema creation: Y
? What best describes your project: Single object with fields
? Do you want to edit the schema now: Y

Update the schema with the following type:

type Message @model {
  id: ID!
  title: String!
  color: String
  image: String
  createdAt: String
}

Now that we have created the GraphQL API and we have a GraphQL Schema to work with, we can create the Models we'll need for working the the local DataStore API (based on the GraphQL Schema):

$ amplify codegen models

This will create a new folder in our project called models. Using the models in this folder, we can start interacting with the DataStore API.

Next, we can deploy the API:

$ amplify push --y

Now that the back end has been deployed, we can start writing the client-side code.

Writing the client-side code

First, open src/index.js and configure the Amplify app by adding the following code below the last import:

import Amplify from '@aws-amplify/core'
import config from './aws-exports'
Amplify.configure(config)

Notice that we are now importing from @aws-amplify/core instead of aws-amplify.

Next open App.js and update it with the following code:

import React, { useState, useEffect } from 'react'
import { SketchPicker } from 'react-color'
import { Input, Button } from 'antd'
import { DataStore } from '@aws-amplify/datastore'
import { Message} from './models'

const initialState = { color: '#000000', title: '', }
function App() {
  const [formState, updateFormState] = useState(initialState)
  const [messages, updateMessages] = useState([])
  const [showPicker, updateShowPicker] = useState(false) 
  useEffect(() => {
    fetchMessages()
    const subscription = DataStore.observe(Message).subscribe(() => fetchMessages())
    return () => subscription.unsubscribe()
  }, [])
  async function fetchMessages() {
    const messages = await DataStore.query(Message)
    updateMessages(messages)
  }
  function onChange(e) {
    if (e.hex) {
      updateFormState({ ...formState, color: e.hex})
    } else { updateFormState({ ...formState, [e.target.name]: e.target.value}) }
  }
  async function createMessage() {
    if (!formState.title) return
    await DataStore.save(new Message({ ...formState }))
    updateFormState(initialState)
  }
  return (
    <div style={container}>
      <h1 style={heading}>Real Time Message Board</h1>
      <Input
        onChange={onChange}
        name="title"
        placeholder="Message title"
        value={formState.title}
        style={input}
      />
      <div>
        <Button onClick={() => updateShowPicker(!showPicker)}style={button}>Toggle Color Picker</Button>
        <p>Color: <span style={{fontWeight: 'bold', color: formState.color}}>{formState.color}</span></p>
      </div>
      {
        showPicker && <SketchPicker color={formState.color} onChange={onChange} />
      }
      <Button type="primary" onClick={createMessage}>Create Message</Button>
      {
        messages.map(message => (
          <div key={message.id} style={{...messageStyle, backgroundColor: message.color}}>
            <div style={messageBg}>
              <p style={messageTitle}>{message.title}</p>
            </div>
          </div>
        ))
      }
    </div>
  );
}

const container = { width: '100%', padding: 40, maxWidth: 900 }
const input = { marginBottom: 10 }
const button = { marginBottom: 10 }
const heading = { fontWeight: 'normal', fontSize: 40 }
const messageBg = { backgroundColor: 'white' }
const messageStyle = { padding: '20px', marginTop: 7, borderRadius: 4 }
const messageTitle = { margin: 0, padding: 9, fontSize: 20  }

export default App;

Let's walk through the most important parts of what's going on in this component.

  • First, we import the DataStore API from Amplify as well as the Message model.
  • We create three pieces of component state using the useState hook:
    • formState - This object manages the state for the form, including the message title and message color that will be used to display the background color of the message
    • messages - This will manage the array of messages once they are fetched from DataStore
    • showPicker - This will manage a boolean value that will be toggled to show and hide a color picker to fill the color value for the message (by default, is set to black)
  • When the component loads, we fetch all messages by invoking the fetchMessages function and create a subscription to listen to message updates. When a subscription is fired, we also invoke the fetchMessages function because we know there has been an update and we would like to update the app with the most recent data coming back from the API.
  • The fetchMessages function calls DataStore.query and then updates the component state with the returned array of messages
  • The onChange handler handles the updates to both the form input as well as the color picker being changed
  • In createMessage, we first check to makes sure the title field is populated. If it is, we save the message using DataStore.save and then reset the form state.

Next, let's test it out:

$ npm start

Testing the offline functionality

Now, try going offline, creating a new mutation and then coming back online. You should notice that, when back online, the app takes all of the messages created when you were offline and creates them in the database.

To verify this, open the AppSync API in the AWS Console:

$ amplify console api

? Please select from one of the below mentioned services: GraphQL

Next, click on Data Sources and then open the Message Table resource. You should now see the items in the Message Table.

Testing the real-time functionality

To test out the real-time functionality, open a another window so that you have two windows running the same app.

Create a new item in one window, and see the update come through automatically in the other window.

Summary

  1. Amplify enables two different APIs to interact with AppSync, the API category as well as DataStore

  2. When using DataStore, you are no longer sending HTTP requests directly to the API. Instead, you are writing to the local storage engine and DataStore then takes care of the syncing to and from the cloud.

  3. Amplify DataStore works offline by default.

References

https://github.com/dabit3/full-stack-serverless-code/tree/master/rtmessageboard

@cnegrisanu
Copy link

cnegrisanu commented May 29, 2020

Hi @dabit3, coming back with the same Datastore and Auth question. If I setup my schema with @auth directives for my objects and I login with one user, create some records and then logout and login with a different user, how can I get Datastore to only retrieve the records for the second user and not all records? Currently if I call DataStore.query("Post") I get all Posts from all the users that have been synced to the IndexedDB store. I guess I could do DataStore.query(Post, p => p.owner === loggedInUser) to only show the records of the current user but that doesn't mean that someone could not open the DevTools and look at the other records. Should I manually remove the IndexedDB records from the store upon each logout? Or maybe store a value of the previously loggedIn user and upon signup with a different ID, remove the store and re-query? I believe other people will have the same question so it would be really helpful to treat this scenarios in the official Docs for DataStore. Thank you very much!

@ytr0
Copy link

ytr0 commented Jul 14, 2020

@cnegrisanu
I'm facing the exact same issue.
Did you find out a good solution?

@myfairshare
Copy link

@cnegrisanu
I'm facing the exact same issue.
Did you find out a good solution?

I think that has been added to the docs in the synching to the cloud tab.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment