We often need to model tree data in Firestore. Nested comments, categories, graphs, and groups are just some examples. Modeling is difficult, but it can be done with a few hacks.
TL;DR#
When you want to query nested data in Firestore, you must model the data so that one query can get all documents. This can be possible by using a long string for the path.
We Need One Query#
You can easily model the data for multiple queries. The original thought was to query all posts where the parent equals some ID. If there were children, query them separately. The problem with this method is that you will have multiple calls to Firestore. While you would be reading the same documents, you don’t want to have several fetches to Firestore on one page, especially when it can be avoided.
Tree Demo in SvelteKit#
This demo uses SvelteKit, so I recommend you read the Firebase SvelteKit post first. There are many advanced techniques, but I am only concentrating on the data model for this post. I use Firebase Client, but I could have equally used Firebase Admin with SvelteKit Form Actions.
Hierarchy#
A tree is data that can belong to other data in a hierarchy. To nest data, we must use startsWith
character hack mentioned in the Fuzzy Full-Text Search post. The algorithm will be slightly different, but we will order the path
and parent
variables to get the desired results.
Tree Data Model#
We are building a comment system similar to a Reddit Clone, where people can comment on one post in a tree fashion.
Short Document ID#
We will create a short ID for faster searching and longer string storage. Theoretically, a document can be as big as 1MB, so one field could be up to that large.
const id = doc(
collection(db, 'comments')
).id.substring(0, 5);
This limits the document ID to 5 characters, although you can use whatever length you like.
Creating a Comment Document#
We will create the document with a few fields.
export const addComment = async (event: SubmitEvent) => {
const { text, parent: _parent, formElement } = getComment(event);
const level = _parent.split('/').length + 1 || 1;
const parent = _parent.split('/').join('_');
const currentUser = auth.currentUser;
if (!currentUser) {
throw 'No user!';
}
// create short ID
const id = doc(
collection(db, 'comments')
).id.substring(0, 5);
const path = parent ? `${parent}_${id}` : id;
try {
await setDoc(
doc(db, `comments/${id}`),
{
createdBy: currentUser.uid,
createdAt: serverTimestamp(),
text,
level,
path,
parent,
votes: 0
}
);
formElement.reset();
} catch (e) {
if (e instanceof FirebaseError) {
console.error(e);
}
}
};
text
field stores the commentlevel
stores the level number from the rootpath
stores the full path to the document./
will be replaced with_
for searching.parent
stores the full path to the parent document, or null./
will be replaced with_
for searching.votes
is not used in this demo, but could be used to create a voting mechanism similar to Reddit.
Deleting a Comment#
When we delete a comment, we must also delete all of its children. We search for them, then use batch to delete them in a transaction.
export const deleteComment = async (path: string) => {
const _path = path.split('/').join('_');
const childrenSnap = await getDocs(
query(
collection(db, 'comments'),
...startsWith('path', _path)
)
);
if (childrenSnap.empty) {
throw 'No ids!';
}
const batch = writeBatch(db);
try {
childrenSnap.docs.map(doc => {
batch.delete(doc.ref);
});
await batch.commit();
} catch (e) {
if (e instanceof FirebaseError) {
console.error(e);
}
}
};
Starts With#
We have a shorter version of the startsWith in Fuzzy Text Search. This allows you to search for something which starts with a character. We need to use it with the spread operator, as we cannot return more than one item, only an array of items.
export const startsWith = (
fieldName: string,
term: string
) => {
return [
orderBy(fieldName),
startAt(term),
endAt(term + '~')
];
};
Querying the Tree#
We query the tree by searching for comments
ordered by the parent
document. If we want the votes feature, we order by the votes
secondly. This demo does not use votes, but the feature could easily be added.
export const useComments = (
term: string | null = null,
levels: number[] = []
) => {
const user = useUser();
// filtering comments depend on user
return derived<
Readable<UserType | null>,
CommentType[]
>(
user, ($user, set) => {
set([]);
if (!$user) {
return;
}
const queryConstraints = [];
if (term) {
const _term = term.split('/').join('_');
queryConstraints.push(
where('path', '>=', _term),
where('path', '<=', _term + '~')
);
}
if (levels?.length) {
queryConstraints.push(
where('level', 'in', levels)
);
}
return onSnapshot(
query(
collection(db, 'comments'),
where('createdBy', '==', $user.uid),
orderBy('path'),
orderBy('votes', 'desc'),
...queryConstraints
), (q) => set(snapToData(q))
);
});
};
If we enter a term
when creating this query, it will only display items under that parent. This is where we need the startsWith hack again.
Snapshot Data#
We must take our data and modify it in a tree format. Remember our data is sorted correctly, but we need to display it in a tree fashion.
export const snapToData = (
q: QuerySnapshot<DocumentData, DocumentData>
) => {
// creates comment data from snapshot
if (q.empty) {
return [];
}
const comments = q.docs.map((doc) => {
const data = doc.data({
serverTimestamps: 'estimate'
});
const createdAt = data['createdAt'] as Timestamp;
const comment = {
...data,
path: data['path']?.split('_').join('/'),
parent: data['parent']?.split('_').join('/'),
createdAt: createdAt.toDate(),
id: doc.id
};
return comment;
}) as CommentType[];
// create children from IDs
const _comments = nestedComments(comments);
if (dev) {
console.log(_comments);
}
return _comments;
}
Our parent
and path
are reformed to a path format.
Threaded Comments#
To get our data in the right place, we need to put all children in a children
array, and all their children in a children
array etc. This will allow us to display the data correctly in our component template.
export const nestedComments = (comments: CommentType[]) => {
const commentMap: Record<string, CommentType> = {};
const result: CommentType[] = [];
// Initialize the commentMap and set up children arrays
for (let i = 0; i < comments.length; i++) {
const comment = comments[i];
comment.children = [];
commentMap[comment.path] = comment;
}
// Nest comments under their parents, or handle cases where parent is missing
for (const comment of comments) {
// If the comment doesn't have a parent, add it to the top-level result immediately
if (!comment.parent) {
result.push(comment);
continue;
}
// If the parent is not in the dataset, handle the child as a top-level comment (or change as needed)
const parent = commentMap[comment.parent];
if (!parent) {
result.push(comment);
continue;
}
// Add the comment to the parent's children array if the parent exists
parent.children!.push(comment);
}
return result;
};
Recursive Component#
Now our data is ready to be displayed recursively in our component.
<script lang="ts">
import Comment from '$lib/components/comment.svelte';
import Comments from '$lib/components/comments.svelte';
export let comments: CommentType[];
</script>
{#each comments as comment}
<Comment text={comment.text} path={comment.path}>
{#if comment.children?.length}
<Comments comments={comment.children} />
{/if}
</Comment>
{/each}
Depth#
Finally, we may want to display only a certain depth in our tree. Remember, a depth is calculated from the current post in whatever position it may be in. For example, a comment in the 5th position but the 3rd position on that page will have a depth of 5 on the main page and 3 on that comment page.
where('level', 'in', levels)
We calculate the levels on that page and add them to the query in an array (or multiple where clauses). Three positions starting at level 3, will have a value of [3, 4, 5]
in the levels array. This works as a filter, so we don’t over-fetch unneeded data.
About the Demo Tree#
This demo creates each comment tree using the user’s ID. A real comment tree will probably use a post ID. This is only for demo purposes, so users can have their own tree; we don’t want spam.
Bonus: Votes#
With votes
being the second orderBy
clause, you could easily modify this demo to be a Reddit Clone. Up and Down clicks on the votes would increment the votes counter on the post. I didn’t do that for simplification, but it would be an easy addition.
⚠️ Don’t forget to add the necessary indexes from the console.log
when you run the demo locally using your database.
Demo: Vercel Serverless
Repo: GitHub