1.0.0-2 • Published 5 months ago

@typed-firestore/react v1.0.0-2

Weekly downloads
-
License
MIT
Repository
github
Last release
5 months ago

Typed Firestore - React

Elegant, strongly-typed React hooks for handling Firestore documents. This is currently implemented as a stronger-typed, more focussed and opinionated abstraction on top of react-firebase-hooks.

This library uses typing similar to @typed-firestore/server, so you can be consistent between server and client code.

Features

  • Type your collections once, and infer from that.
  • Uses the same FsDocument abstraction as found in typed-firestore-server for consistency between front and backend code.
  • Throws errors instead of returning them. See throwing errors for motivation.

Installation

pnpm add @typed-firestore/react

Usage

Create a file in which you define refs for all of your database collections, and map each to the appropriate type, as shown below.

// db-refs.ts
import { CollectionReference } from "firebase/firestore";
import { db } from "./firestore";
import { User, WishlistItem, Book } from "./types";

export const refs = {
  /** For top-level collections it's easy */
  users: db.collection("users") as CollectionReference<User>,
  books: db.collection("books") as CollectionReference<Book>,
  /** For sub-collections you could use a function that returns the reference. */
  userWishlist: (userId: string) =>
    db
      .collection("users")
      .doc(userId)
      .collection("wishlist") as CollectionReference<WishlistItem>,

  /** This object never needs to change */
} as const;

Then in a component you would do something like this:

import { useDocument } from "@typed-firestore/react";
import { UpdateData } from "firebase/firestore";

export function DisplayName({userId}: {userId: string}) {

/** The returned user is typed as FsMutableDocument<User> */
const [user, isLoading] = useDocument(refs.users, userId);

function handleUpdate() {
  /**
   * Here you can only pass in properties that exist on the User type.
   * FieldValue is allowed to be used to set things like Timestamps.
   */
  user.update({modifiedAt: FieldValue.serverTimestamp()})
}

if (isLoading) {
  return <LoadingIndicator/>;
  }

  return <div onClick={handleUpdate}>{user.data.displayName}</div>;
}

Notice how we did not need to import the User type, or manually type our update data to satisfy the type constraints. Everything flows from the collection refs. Re-using collection refs also avoids having to remember the names and write them correctly.

API

Hooks

HookDescription
useDocumentUse a document and subscribe to changes
useDocumentDataUse only the data part of a document and subscribe to changes
useDocumentMaybeUse a document that might not exist
useDocumentOnceUse a document once and do not subscribe for changes
useCollectionQuery a collection and subscribe for changes

Functions

Besides hooks, this library also provides a set of functions that can be used outside of a component. For example when you want to fetch data with ReactQuery.

const { data, isError } = useQuery({
  queryKey: [collectionRef.path, documentId],
  queryFn: () => getDocument(collectionRef, documentId),
});
FunctionDescription
getDocumentFetch a document
getDocumentDataFetch only the data part of a document
getDocumentMaybeFetch a document that might not exist
getDocumentFromTransactionFetch a document as part of a transaction
getDocumentFromTransactionMaybeFetch a document that might not exist as part of a transaction

Working with Documents

I prefer to perform all document mutations server-side via an API call, especially if older versions of your app could be around for a while like with react-native, because a bug in client-side code could have lasting effects on the consistency of your data.

Facilitating client-side writes in a safe way also requires you to write database rules for your Firestore documents, which can get very complex, so mutating documents server-side is not only easier to to reason about but also secure by default.

The default immutable document type is FsDocument<T>. Use this type when you write functions that take data without needing to change it.

export type FsDocument<T> = {
  id: string;
  data: T;
};

The hooks return mutable documents, but thanks to TS structural typing, you can simply pass them along as if they were immutable FsDocument types.

Each document conveniently contains a typed update function, which only allows you to pass in properties that exist on the type. Firestore FieldValue is allowed to be used to set things like Timestamps.

The original document ref is also available, in case you need functionality that is not covered by this library.

export type FsDocument<T> = {
  id: string;
  data: T;
  ref: DocumentReference<T>;
  update: (data: UpdateData<T>) => Promise<void>;
};

Or, in the case of transactions:

export type FsDocument<T> = {
  id: string;
  data: T;
  ref: DocumentReference<T>;
  update: (data: UpdateData<T>) => Transaction;
};

@todo write about use in transactions.

Throwing Errors

The hooks in this library throw errors, which is not a common practice, but I think it is warranted.

In my experience, runtime exceptions for Firestore documents and collection queries are very rare. By throwing we can avoid having to handle errors and loading state separately in every calling context, and optimize for the happy-path.

The most common errors are:

  1. An index is required but not created yet.
  2. A document does not exist

Both of are likely to be caught during development and testing and should not occur in production code.

In some cases you actually know upfront that the document might not exist, so for those instances we have the *Maybe variants like useDocumentMaybe(). These functions do not throw but simply return undefined if the document does not exist. In this case it is not considered an error.

1.0.0-2

5 months ago

1.0.0-1

5 months ago

1.0.0-0

5 months ago