Skip to content

TanStack

TanStack is a set of utilities for building web applications.

TanStack Query is a data-fetching and state management library.

Electric and TanStack

Electric works very well together with TanStack Query, where Electric provides the read-path sync and TanStack provides a local write-path with optimistic state.

Illustration of an Electric - TanStack integration
Green shows read-path sync via Electric. Red shows write-path via TanStack.

In this configuration, Electric and TanStack can provide a fully offline-capable system with active-active replication of both reads and writes.

Example

The example below shows a simple todo application that uses Electric for read-path sync and TanStack for local optimistic writes.

Electric is used to sync a shape. TanStack is used to apply mutations and maintain optimistic state. When a mutation is confirmed, it cleares the optimistic state. When the component renders, it merges the optimistic state into the shape data.

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,
  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>
  )
}

See the tanstack-example for the full source code.

Help wanted Good first issue

We have an open GitHub issue tracking this if you'd like to contribute a library based on the tanstack-example that integrates Electric and TanStack into a higher level interface.

Please leave a comment or ask on Discord if you'd like any pointers or to discuss how best to approach this.