UseSyncExternalStore hook with FireStore

React 18 has added multiple new hooks to its arsenal, useSyncExternalStore is generally underused. It is useful to integrate with existing non React code like any data store library, external and changing data source. Let’s see how to implement it with real-life examples.

Specification

const snapshot = useSyncExternalStore(subscribe, getSnapshot)

To connect to the external store this hook expects 2 functions from the external store.

subscribe: This function takes the listener function as input and returns a function that unsubscribes.

getSnapshot: This function should return the current snapshot of the data when called.

How to implement?

Let’s implement useSyncExternalStore with Firestore.

Setup

  • Create react project using create-react-app

    > npx create-react-app my-app
    > cd my-app
    
  • Install dependencies

    > npm install firebase
    # react-hook-form is for forms and inputs handling in react
    > npm install react-hook-form
    
  • Create files

    > touch src/firease.js
    > touch src/store.js
    
  • Styling is applied using tailwindcss

Firebase initialization in firease.js:

import { initializeApp } from 'firebase/app'
import { getFirestore } from 'firebase/firestore'

const firebaseConfig = {
  // TODO: Add your firebase configs here
}
export const app = initializeApp(firebaseConfig)
export const db = getFirestore(app)

Create a store with Firestore in store.js:

import { db } from './firebase'
import {
  collection,
  onSnapshot,
  query,
  orderBy,
  addDoc,
  serverTimestamp,
  setDoc,
  doc,
} from 'firebase/firestore'

export default function externalStore() {
  // Store logic and functions
  const addTodo = async (todo) => {
    try {
      await addDoc(collection(db, 'todos'), {
        name: todo,
        isCompleted: false,
        createdAt: serverTimestamp(),
      })
    } catch (e) {
      console.error('Error adding document: ', e)
    }
  }
  const completeTodo = async (id, isCompleted) => {
    try {
      await setDoc(doc(db, 'todos', id), { isCompleted }, { merge: true })
    } catch (e) {
      console.error('Error updating document: ', e)
    }
  }

  // Data
  let todos = []

  // Subscribe function for React sync external store hook
  const subscribe = (listner) => {
    return onSnapshot(
      query(collection(db, 'todos'), orderBy('createdAt')),
      (querySnapshot) => {
        // Update data
        todos = querySnapshot.docs.map((doc) => {
          return {
            id: doc.id,
            ...doc.data(),
          }
        })
        // Call listener function after data updates
        listner()
      }
    )
  }

  // This function will get called at any time within the render cycle. But it mostly gets executed after the data update is notified through subscribe
  // IMPORTANT: do not return a new object, else React will go into infinite render
  const getSnapshot = () => {
    return todos
  }

  return {
    subscribe,
    getSnapshot,
    addTodo,
    completeTodo,
  }
}

Use store within the component in App.js:

import './App.css'
import { useSyncExternalStore } from 'react'
import { useForm } from 'react-hook-form'
import externalStore from './store'

// NOTE: Store is initialized outside the React component
const store = externalStore()

function App() {
  // Attach Custom Store with component
  const todos = useSyncExternalStore(store.subscribe, store.getSnapshot)

  // Handle component specific logic
  const { register, handleSubmit } = useForm()
  const onSubmit = (data) => {
    store.addTodo(data.todo)
  }
  const handleComplete = (id, isCompleted) => {
    store.completeTodo(id, isCompleted)
  }

  return (
    <div>
      <h1>TODOs</h1>
      <div className="flex flex-col space-y-2">
        {todos &&
          todos.map((todo) => (
            <Todo key={todo.id} todo={todo} onChange={handleComplete} />
          ))}
      </div>
      <form
        className="mt-2 flex flex-row space-x-2"
        onSubmit={handleSubmit(onSubmit)}
      >
        <label>Add To Do</label>
        <input type="text" {...register('todo', { required: true })}></input>
        <button type="submit">Add</button>
      </form>
    </div>
  )
}

function Todo({ todo, onChange }) {
  return (
    <div>
      <input
        type="checkbox"
        id={todo.id}
        name={todo.id}
        onChange={(evt) => {
          onChange(evt.target.id, !(evt.target.value === 'true'))
        }}
        checked={todo.isCompleted}
        value={todo.isCompleted}
      ></input>
      <label htmlFor={todo.id}>{todo.name}</label>
    </div>
  )
}

export default App

Find implementation with codesandbox here

Note: You will need to provide Firebase configuration to make it work