UseSyncExternalStore hook with rxjs

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 rxjs
    # react-hook-form is for forms and inputs handling in react
    > npm install react-hook-form
    # nanoid is for unique id generation
    > npm install nanoid
    
  • Create files

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

Create a store with rxjs in store.js:

import { BehaviorSubject } from 'rxjs'
import { nanoid } from 'nanoid'

export default function externalStore() {
  const subject = new BehaviorSubject([]) // [] is initial empty ToDos list

  // Store logic and functions
  const addTodo = async (name) => {
    const todo = {
      id: nanoid(),
      name,
      createdAt: new Date().getTime(),
      isCompleted: false,
    }
    subject.next([...subject.value, todo]) // Return newly created object
  }
  const completeTodo = async (id, isCompleted) => {
    const todo = subject.value.find((val) => val.id === id)
    todo.isCompleted = isCompleted
    subject.next([...subject.value]) // Return newly created object
  }

  // Subscribe function for React sync external store hook
  const subscribe = (listener) => {
    const subscription = subject.subscribe(listener)
    return () => subscription.unsubscribe()
  }

  // 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 subject.value
  }

  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