TypeScript in React – How to Manage State with Firebase Cloud Firestore

As a full-stack web developer, I‘m always looking for ways to improve the maintainability and reliability of my code. That‘s why I love using TypeScript with React. TypeScript is a typed superset of JavaScript that catches errors at compile-time, making it easier to write correct code and avoid bugs.

When building React applications that need to store and sync data across multiple clients, Firebase Cloud Firestore is my go-to choice. It‘s a scalable NoSQL database that integrates seamlessly with Firebase‘s other backend services like authentication and hosting.

In this expert guide, I‘ll show you how to use TypeScript and Firebase Cloud Firestore to manage state in a React app. By the end, you‘ll have a solid understanding of Firestore‘s data model and how to perform CRUD operations in a type-safe way. Let‘s get started!

Setting Up a React Project with TypeScript

The easiest way to start a new React project with TypeScript is by using Create React App with the TypeScript template:

npx create-react-app my-app --template typescript

This command will generate a new directory called my-app with the following structure:

my-app/
  README.md
  node_modules/
  package.json
  public/
    index.html
    favicon.ico
  src/
    App.css
    App.tsx 
    index.css
    index.tsx

The .tsx extension indicates that the file contains TypeScript code that will be transpiled to plain JavaScript.

To customize the TypeScript compiler options, you can edit the tsconfig.json file in the root directory. Here are some recommended settings:

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable", 
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": [
    "src"
  ]
}

The strict flag enables a range of type checking behavior that results in stronger guarantees of program correctness. I highly recommend keeping this enabled to get the most benefit from TypeScript.

If you need to use any external libraries that don‘t have built-in type definitions, you‘ll need to install the corresponding @types package. For example, to add types for Firebase, run:

npm install --save-dev @types/firebase

With the project set up, let‘s move on to integrating Firebase.

Connecting Firebase to the React App

To use Firebase in your React app, you first need to install the Firebase JS SDK:

npm install firebase

Next, go to the Firebase Console, create a new project, and add a web app. This will give you a configuration object to initialize Firebase in your code:

// src/firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = {
  apiKey: "your-api-key",
  authDomain: "your-auth-domain",
  projectId: "your-project-id",
  // ...
};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

export { db };

Now you can import the db object anywhere in your code to get a reference to the Firestore database.

Firestore has a simple and flexible data model based on collections and documents. A collection is a container for documents, which are lightweight records containing fields mapped to values. Documents can contain subcollections, forming a hierarchical data structure.

To reference a collection or document, you use the collection and doc functions:

import { collection, doc } from "firebase/firestore"; 

const usersCollection = collection(db, "users");
const userDocument = doc(db, "users", "uid123");

With these references, you can perform read and write operations on your Firestore data.

Managing State with React Context

For many apps, it makes sense to keep your Firestore data in a global state that can be accessed by any component. A common way to achieve this is with the React Context API.

First, define the shape of your app state with a TypeScript interface:

// src/types.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

export interface AppState {
  users: User[];
  loading: boolean;
  error: string | null;
}

Then create a context with a default value:

// src/AppContext.tsx
import React from "react";
import { AppState } from "./types";

const defaultState: AppState = {
  users: [],
  loading: true,
  error: null,
};

export const AppContext = React.createContext<AppState>(defaultState);

To provide the state to your component tree, wrap it in a context provider component:

export const AppProvider: React.FC = ({ children }) => {
  const [state, setState] = React.useState<AppState>(defaultState);

  // TODO: Load initial data from Firestore

  return (
    <AppContext.Provider value={state}>
      {children}  
    </AppContext.Provider>
  );
};

Finally, use the useContext hook to consume the state in any child component:

import { AppContext } from "./AppContext";

const UserList: React.FC = () => {
  const { users, loading, error } = React.useContext(AppContext);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>  
      ))}
    </ul>  
  );
};

Now let‘s see how to populate the initial state by querying Firestore.

Querying and Displaying Firestore Data

To fetch documents from a Firestore collection, you can use the getDocs function:

import { getDocs, QuerySnapshot } from "firebase/firestore";  

const querySnapshot = await getDocs(usersCollection);
const users = querySnapshot.docs.map(doc => {
  return { id: doc.id, ...doc.data() } as User;
});

setState({ users, loading: false });

Here we‘re using the as keyword to tell TypeScript that the return value is of type User. This is a common pattern when working with Firestore because the SDK does not provide built-in type information.

You can also use the query function to filter and order the results:

import { query, where, orderBy } from "firebase/firestore";

const q = query(
  usersCollection, 
  where("age", ">", 18),
  orderBy("name")
);

const querySnapshot = await getDocs(q);
// ...

To get real-time updates from a query, you can listen to its snapshot changes:

import { onSnapshot } from "firebase/firestore";

React.useEffect(() => {
  const unsubscribe = onSnapshot(q, snapshot => {
    const users = snapshot.docs.map(doc => {
      return { id: doc.id, ...doc.data() } as User;
    });

    setState({ users, loading: false });
  });

  return unsubscribe;
}, []);

The onSnapshot function returns an unsubscribe function that you can call to stop listening for changes. It‘s important to do this when the component unmounts to avoid memory leaks.

Updating and Deleting Documents

To update a document, you can use the updateDoc function:

import { updateDoc } from "firebase/firestore";

const userRef = doc(usersCollection, "uid123");

await updateDoc(userRef, {
  age: 30,  
  location: "New York",
});  

And to delete a document, use deleteDoc:

import { deleteDoc } from "firebase/firestore";  

await deleteDoc(userRef);

Both functions return a promise that resolves when the operation is complete.

Optimizing Performance

As your app grows in complexity, it‘s important to keep an eye on performance. Here are a few tips for optimizing your Firestore usage:

  • Use memoization to avoid unnecessary re-renders when your state changes. React‘s useMemo and useCallback hooks can help with this.

  • Throttle or debounce expensive operations like fetching large datasets. The lodash library provides utility functions for this.

  • Paginate queries to load data incrementally as the user scrolls or clicks a "load more" button. You can use the limit and startAfter methods to achieve this.

  • Consider using Firestore‘s offline persistence to cache data locally on the client. This can speed up load times and provide a smoother user experience.

Advanced Firestore Features

In addition to the basics, Firestore has some advanced features that can come in handy for more complex use cases:

  • Transactions let you perform atomic reads and writes to ensure data consistency. Use the runTransaction function to execute multiple operations in a single transaction.

  • Batched writes let you update multiple documents in a single API call, which can help reduce latency and cost. Use the writeBatch function to create a batch of write operations.

  • Security rules let you define granular access controls on your Firestore data. You can use the rules language to specify who can read and write to specific collections and documents.

Deploying the App

When you‘re ready to deploy your app to production, you can use Firebase Hosting for free. First, build your React app for production:

npm run build 

This will generate an optimized bundle in the build directory.

Next, install the Firebase CLI:

npm install -g firebase-tools

And log in to your Firebase account:

firebase login

Finally, deploy your app with a single command:

firebase deploy

Your app will be live at the domain provided by Firebase Hosting. You can also configure a custom domain in the Firebase Console.

Conclusion

TypeScript and Firebase Cloud Firestore are a powerful combination for building scalable and maintainable web applications. By leveraging TypeScript‘s static typing and Firestore‘s real-time sync capabilities, you can create React apps that are both robust and performant.

In this guide, we covered the basics of setting up a React project with TypeScript, connecting it to Firebase, and managing state with the Context API. We also looked at how to perform CRUD operations on Firestore data and optimize performance with techniques like pagination and memoization.

Finally, we explored some advanced features of Firestore like transactions and security rules, and saw how to deploy the app to production with Firebase Hosting.

I hope this guide has been helpful in demystifying TypeScript and Firebase development. With these tools in your arsenal, you‘ll be well-equipped to tackle even the most complex web app projects. Happy coding!