DEV Community

Cover image for How I use Appwrite Databases with Pinia to build my own habit tracker
Vincent Ge
Vincent Ge

Posted on

How I use Appwrite Databases with Pinia to build my own habit tracker

Some context

I'm building a project with Vue and Appwrite called Sisyphus. Much like Sisyphus, we have many tasks that are a real uphill grind everyday. And like Sisyphus, we repeatedly push that figurative boulder up the hill each day, anyway.

I built Sisyphus to help track my own habits with an UI like a GitHub commit history.

Image description

Here's a GitHub commit history for reference.

Image description

My inspiration

I used Appwrite to implement authentication and database APIs for my app. The SDK API is very similar to a REST API, which gives you lots of flexibility.

Inspired by a video by my colleague @dennisivy11 I decided to share how I use Appwrite Databases, too.

Do this when writing database queries with Appwrite - YouTube

How to write less, and cleaner code when working with an Appwrite database.Instructor: https://twitter.com/dennisivy11 / https://www.linkedin.com/in/dennis-i...

favicon youtube.com

Here's how you can use Appwrite Databases with Vue 3, Pinia, and TypeScript to create elegant data stores that can be consumed in your components.

The Databases API

Here's what CRUD operations look like with Appwrite's SDKs.

// create a document
await 
databases.createDocument(
    'sisyphys', 
    'boulders'
    ID.unique, 
    {foo: 'bar'}
)
// read some documents
await databases.listDocuments(
    'sisyphys', 
    'boulders'
);
// update a document
await databases.updateDocument(
    'sisyphys', 
    'boulders'
    'drink-water'
    {food: 'baz'}
);
// delete a document
await databases.deleteDocument(
    'sisyphys', 
    'boulders'
    'drink-water'
);
Enter fullscreen mode Exit fullscreen mode

Simple, RESTful, but not useful for building a UI. I need these operations to play nice with a store like Pinia to manage state and share data across components.

My data

I also have two collections in Appwrite with the following structure:

export type Pushes = Models.Document & {
  date: string;
  distance: number;
  boulderId: string;
}

export type Boulder = Models.Document & {
  name: string;
  description: string;
  distance: number;
}
Enter fullscreen mode Exit fullscreen mode

Pushes tracks each time I pushed a boulder forward, it describes how far I pushed, on which day, and which goal/boulder.

Boulder tracks the goals I've created, their name, a short description, and the goal of how far I'd ideally push that boulder forward in a day in distance.

Looking back at the UI, the data needs to be consumed at these levels:

  • My boulder container component, which creates a boulder card for each goal I set.
  • Each boulder card, which needs to render a history of my progress on that goal.
  • Each form in my app, like when I create new goals or push a boulder and move my goal forward.

Creating a generic store for Appwrite collections

All Appwrite collections share these commonalities:

  • Belong to a database and has it's own ID
  • Data extends Models.Document, containing information like $id, permissions, $createdAt, $updatedAt, etc.
  • Has create, list, get, update, delete.

This means code related to these commonalities are going to be the same for each store I need for each Appwrite Databases Collection.

So, here's how I implement a base collection for all my Appwrite Collections.

Generic interface

// This is my base store
import { databases, ID, Query, type Models } from '@/lib/appwrite'
import { defineStore } from 'pinia'

export function defineCollection<Type>(
  name: string,
  database: string,
  collection: string,
) {
  return defineStore({
    id: name,
    state: (): => {
      return {
        documents: [],
      }
    },
    getters: {
       // we'll cover these later
    },
    actions: {
       // we'll cover these later
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

My collection store is a factory that returns a define store based on the name of the collection, the database id, and the collection id.

Notice the use of the generic Type, we pass this in from each collection store that composes/extends this base Pinia store with the structure of the collection.

For example:

// This is how I extend my store with a type
export type Boulder = Models.Document & {
  name: string;
  description: string;
  distance: number;
}

const COLLECTION = 'boulders';

const collectionStore = defineCollection<Boulder>(
  'collection-' + COLLECTION,
  import.meta.env.VITE_DATABASES_ID,
  COLLECTION
)
Enter fullscreen mode Exit fullscreen mode

Here I'm extending the base collection store with the type <Boulder> so I get helpful type hints when I consume the store.

The state of the store is a list of documents that extends this type.

Getters and actions

Pinia implements getters and actions to help you interact with the app. Here's the generic getters and actions used by all my collection stores.

Here are my getters:

// ... skipped code in my base collection store
    getters: {
      count(state) {
        return state.documents.length
      }
    },
Enter fullscreen mode Exit fullscreen mode

I don't need a lot of fancy getters in my base store, just something to return a count.

Here are my CRUD actions:

// ... skipped code in my base collection store
    actions: {
      async get(id: string) {
        return databases.getDocument(
          database,
          collection,
          id
        )
      },
      async list(queries = [] as string[]) {
        return databases.listDocuments(
          database,
          collection,
          queries
        )
      },
      async create(data: any) {
        return databases.createDocument(
          database,
          collection,
          ID.unique(),
          data
        )
      },
      async update(id: string, data: any) {
        return databases.updateDocument(
          database,
          collection,
          id,
          data
        )
      },
    // ... more actions
Enter fullscreen mode Exit fullscreen mode

They just implement a simpler version of the existing SDK methods. Let's me reduce my method calls from this:

databases.createDocument(
    'sisyphys', 
    'boulders'
    ID.unique, 
    {foo: 'bar'}
)
Enter fullscreen mode Exit fullscreen mode

To this:

collection.create({foo: 'bar'})
Enter fullscreen mode Exit fullscreen mode

Why use many lines when few lines do trick?

Some more actions I implement are .load() and .all():

    // ... previous actions
      async load(queries = [], batchSize = 50) {
        try {
          this.documents = await this.all(queries, batchSize)
        }
        catch (error) {
          this.documents = []
        }
      },
      async all(queries = [] as string[], batchSize = 50) {
        let documents = [] as Type[];
        let after = null;

        let response: any = await this.list(
          [
            ...queries,
            Query.limit(batchSize),
          ]
        );

        while (response.documents.length > 0) {
          documents = [...documents, ...response.documents];
          after = response.documents[response.documents.length - 1].$id;
          response = await this.list(
            [
              ...queries,
              Query.limit(batchSize),
              ...(after ? [Query.cursorAfter(after)] : [])
            ]);
        }

        return documents;
      }
Enter fullscreen mode Exit fullscreen mode

I call collection.load() to initialize or update the state of my store and collection.all() lets me responsibly paginate through all my data. These are utility methods I need in all my collection stores.

Of course, if you need methods like collection.page() or collection.nextPage() for a paginated store, you can extend the base store with more actions.

Sisyphus is quite simple, so we load all the data at once.

All these methods make the store generic enough that I will use all these methods in every collection I interface with and provide a more elegant interface to consume data as a store.

Extending my generic store

Remember how I mentioned I extend all my stores? Here's my boulders store as an example.

// My boulders store
export type Boulder = Models.Document & {
  name: string;
  description: string;
  distance: number;
}

const COLLECTION = 'boulders';

const collectionStore = defineCollection<Boulder>(
  'collection-' + COLLECTION,
  import.meta.env.VITE_DATABASES_ID,
  COLLECTION
)

export const useBoulders = defineStore(COLLECTION, () => {
  const parent = collectionStore();

  const boulders = computed(() => {
    return parent.documents;
  })
  const boulder = computed(() => (id: string) => {
    return parent.documents.find((boulder) => boulder.$id === id);
  });

  async function load() {
    return await parent.load();
  }
  async function add(boulder: Boulder) {
    await parent.create(boulder);
  }

  return { boulders, boulder, load, add };
});
Enter fullscreen mode Exit fullscreen mode

I define the data structure expected to be returned by the store and call defineCollection<Boulder> to create this store.

Then, I use Pinia's composition API to extend it with a few methods:

  • I instantiate the generic collectionStore as the parent store.
  • I then add a getter called boulders that just returns a computed with the value of parent.documents. This ensures I maintain reactivity, which means when parent.documents changes, my Vue 3 components are notified.
  • Here's some ✨ magic ✨ , boulder() is a getter that takes in a boulder ID, searches for it, and returns it. Because it's a computed value, it is also reactive.
  • I then wrapped the parents load and create methods to use less generic names that make more sense when correlated with the verbs I use in UI.

Here's another example with my Pushes collection:

import { type Models } from '@/lib/appwrite'
import { defineStore } from 'pinia'
import { defineCollection } from './collection';
import { computed } from 'vue';
import { getToday } from '@/lib/date';
import type { Boulder } from './boulders';

export type Pushes = Models.Document & {
  date: string;
  distance: number;
  boulderId: string;
}

const COLLECTION = 'pushes';

const collectionStore = defineCollection<Pushes>(
  'collection-' + COLLECTION,
  import.meta.env.VITE_DATABASES_ID,
  COLLECTION
)

export const usePushes = defineStore(COLLECTION, () => {
  const parent = collectionStore();
  const pushes = computed(() => (id: string) => {
    return parent.documents.filter((push) => push.boulderId === id) ?? {
      date: '',
      distance: 0,
      boulderId: '',
    };
  });

  async function load() {
    await parent.load([], 500);
  }
  async function push(distance: number, boulderId: number) {
    await parent.create({
      date: getToday().toISOString(),
      distance: distance,
      boulderId: boulderId,
    } as unknown as Pushes);
    load();
  }

  return { pushes, load, push };
});
Enter fullscreen mode Exit fullscreen mode

While I load all pushes at once, each boulder card needs to get filtered results from the store. I again use computed to make sure the filtered value is reactive.

Consuming these stores

Important note about consuming stores like this is that you will need to convert them to refs so they retain reactivity in your UI.

For this, we can destructure them like this:

<script setup lang='ts'>
// ... skipped imports
import { useBoulders } from '@/stores/boulders';
const { boulder } = storeToRefs(useBoulders());
</script>

<template>
          <Boulder v-for="boulder in boulders" :boulderId="boulder.$id"/>
</template>
Enter fullscreen mode Exit fullscreen mode

This would render a list of boulders and dynamically render new ones when the boulders document changes.

Other fun things to think about

When needed, you can extend these stores with Realtime, so that they can listen to updates updates in a collection made by other users in realtime. This would be great for chat apps, etc.

If you're interested in seeing realtime or pagination added to one of these stores, let me know in the comments.

If you haven't tried Appwrite, make sure you give it a spin. It's a open source backend that packs authentication, databases, storage, serverless functions, and all kinds of utilities in a neat API. Appwrite can be self-hosted, or you can use Appwrite Cloud starting with a generous free plan.

Cheers~

Top comments (1)

Collapse
 
sourabpramanik profile image
Sourab Pramanik

A good one👌