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

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.

Here’s a GitHub commit history for reference.

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…

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
);

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;
}

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
},
})
}

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
)

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
}
},

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

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}
)

To this:

collection.create({foo: bar})

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;
}

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 };
});

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 };
});

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 vfor=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, 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~

Leave a Reply

Your email address will not be published. Required fields are marked *