How I use Appwrite Databases with Pinia to build my own habit tracker
Tags: vue, pinia, appwrite, javascript
Published: 2024-04-15 02:25:44

## 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](https://github.com/gewenyu99/sisyphus/tree/main) to help track my own habits with an UI like a GitHub commit history.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9z82x6qlgjwxm5452kdp.png)

Here's a GitHub commit history for reference.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y4q7mir8l9znz91lio2p.png)

## 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.

{% embed https://youtu.be/1ip2aljprvg?si=mVM-SRlBhhiooHMd %}

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.

```js 
// 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'
);
```

Simple, RESTful, but not useful for building a UI. I need these operations to play nice with a store like [Pinia](https://pinia.vuejs.org/) to **manage state** and **share data across components**.

## My data

I also have two collections in Appwrite with the following structure:
```ts
export type Pushes = Models.Document & {
  date: string;
  distance: number;
  boulderId: string;
}

export type Boulder = Models.Document & {
  name: string;
  description: string;
  distance: number;
}
```

**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
```ts
// 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
    },
  })
}
```

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:
```ts
// 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
)
```

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:
```ts
// ... skipped code in my base collection store
    getters: {
      count(state) {
        return state.documents.length
      }
    },
```

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

Here are my CRUD actions:
```ts
// ... 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
```
They just implement a simpler version of the existing SDK methods. Let's me reduce my method calls from this:

```ts 
databases.createDocument(
	'sisyphys', 
	'boulders'
	ID.unique, 
	{foo: 'bar'}
)
```

To this:
``` ts
collection.create({foo: 'bar'})
```

Why use many lines when few lines do trick?

Some more actions I implement  are `.load()` and `.all()`:
``` ts
    // ... 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;
      }
```

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.

```ts
// 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 };
});
```

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:
```ts 
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 };
});
```

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:

```ts
<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>
```

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](https://appwrite.io/docs/apis/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](https://appwrite.io/), 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~