Skip to content

Tanstack

Example of an Electric app using Tanstack Query for optimistic state.

Electric with Tanstack Query

This is an example TanStack application developed using Electric for read-path sync, together with Tanstack Query for local writes with optimistic state.

See the Electric <> Tanstack integration docs for more context and a video of the example running here.

The main Electric code is in ./src/Example.tsx:

tsx
import { getShapeStream, useShape } from "@electric-sql/react"
import {
  useMutation,
  useMutationState,
  useQueryClient,
} from "@tanstack/react-query"
import { matchStream } from "./match-stream"
import { v4 as uuidv4 } from "uuid"
import "./Example.css"

type Item = { id: string }

const baseUrl = import.meta.env.ELECTRIC_URL ?? `http://localhost:3000`
const baseApiUrl = `http://localhost:3001`

const itemShape = () => ({
  url: new URL(`/v1/shape`, baseUrl).href,
  params: {
    table: `items`,
  },
})

async function createItem(newId: string) {
  const itemsStream = getShapeStream<Item>(itemShape())

  // Match the insert
  const findUpdatePromise = matchStream({
    stream: itemsStream,
    operations: [`insert`],
    matchFn: ({ message }) => message.value.id === newId,
  })

  // Insert item
  const fetchPromise = fetch(`${baseApiUrl}/items`, {
    method: `POST`,
    body: JSON.stringify({ id: newId }),
  })

  return await Promise.all([findUpdatePromise, fetchPromise])
}

async function clearItems(numItems: number) {
  const itemsStream = getShapeStream(itemShape())

  // Match the delete
  const findUpdatePromise =
    numItems > 0
      ? matchStream({
          stream: itemsStream,
          operations: [`delete`],
          // First delete will match
          matchFn: () => true,
        })
      : Promise.resolve()

  // Delete all items
  const fetchPromise = fetch(`${baseApiUrl}/items`, { method: `DELETE` })

  return await Promise.all([findUpdatePromise, fetchPromise])
}

export const Example = () => {
  const queryClient = useQueryClient()
  const { data: items } = useShape<Item>(itemShape())
  const submissions: Item[] = useMutationState({
    filters: { status: `pending` },
    select: (mutation) => mutation.state.context as Item,
  }).filter((item) => item !== undefined)

  const { mutateAsync: addItemMut } = useMutation({
    scope: { id: `items` },
    mutationKey: [`add-item`],
    mutationFn: (newId: string) => createItem(newId),
    onMutate: (id) => {
      const optimisticItem: Item = { id }
      return optimisticItem
    },
  })

  const { mutateAsync: clearItemsMut, isPending: isClearing } = useMutation({
    scope: { id: `items` },
    mutationKey: [`clear-items`],
    mutationFn: (numItems: number) => clearItems(numItems),
    onMutate: () => {
      const addMutations = queryClient
        .getMutationCache()
        .findAll({ mutationKey: [`add-item`] })!
      addMutations?.forEach((mut) => queryClient.getMutationCache().remove(mut))
    },
  })

  // Merge data from shape & optimistic data from fetchers. This removes
  // possible duplicates as there's a potential race condition where
  // useShape updates from the stream slightly before the action has finished.
  const itemsMap = new Map<string, Item>()
  if (!isClearing) {
    items.concat(submissions).forEach((item) => {
      itemsMap.set(item.id, { ...itemsMap.get(item.id), ...item })
    })
  } else {
    submissions.forEach((item) => itemsMap.set(item.id, item))
  }

  return (
    <div>
      <div>
        <button
          type="submit"
          className="button"
          onClick={() => addItemMut(uuidv4())}
        >
          Add
        </button>
        <button
          type="submit"
          className="button"
          onClick={() => clearItemsMut(items.length)}
        >
          Clear
        </button>
      </div>
      {[...itemsMap.values()].map((item: Item, index: number) => (
        <p key={index} className="item">
          <code>{item.id}</code>
        </p>
      ))}
    </div>
  )
}