diff --git a/.changeset/lucky-goats-sell.md b/.changeset/lucky-goats-sell.md
new file mode 100644
index 0000000000..c6dabfe651
--- /dev/null
+++ b/.changeset/lucky-goats-sell.md
@@ -0,0 +1,31 @@
+---
+"@latticexyz/store-sync": patch
+---
+
+Added an experimental `@latticexyz/store-sync/react` export with a `SyncProvider` and `useSync` hook. This allows for easier syncing MUD data to React apps.
+
+Note that this is currently only usable with Stash and assumes you are also using Wagmi in your React app.
+
+```tsx
+import { WagmiProvider } from "wagmi";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { SyncProvider } from "@latticexyz/store-sync/react";
+import { createSyncAdapter } from "@latticexyz/store-sync/internal";
+
+export function App() {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+```
diff --git a/packages/store-sync/package.json b/packages/store-sync/package.json
index 3d1b69051d..d42b5d731b 100644
--- a/packages/store-sync/package.json
+++ b/packages/store-sync/package.json
@@ -13,6 +13,7 @@
".": "./dist/index.js",
"./indexer-client": "./dist/indexer-client/index.js",
"./internal": "./dist/exports/internal.js",
+ "./react": "./dist/exports/react.js",
"./postgres": "./dist/postgres/index.js",
"./postgres-decoded": "./dist/postgres-decoded/index.js",
"./recs": "./dist/recs/index.js",
@@ -32,6 +33,9 @@
"internal": [
"./dist/exports/internal.d.ts"
],
+ "react": [
+ "./dist/exports/react.d.ts"
+ ],
"postgres": [
"./dist/postgres/index.d.ts"
],
@@ -95,16 +99,39 @@
"zustand": "^4.3.7"
},
"devDependencies": {
+ "@tanstack/react-query": "^5.56.2",
+ "@testing-library/react": "^16.0.0",
+ "@testing-library/react-hooks": "^8.0.1",
"@types/debug": "^4.1.7",
"@types/node": "20.12.12",
+ "@types/react": "18.2.22",
"@types/sql.js": "^1.4.4",
"@viem/anvil": "^0.0.7",
+ "eslint-plugin-react": "7.31.11",
+ "eslint-plugin-react-hooks": "4.6.0",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
"tsup": "^6.7.0",
"viem": "2.21.19",
- "vitest": "0.34.6"
+ "vitest": "0.34.6",
+ "wagmi": "2.12.11"
},
"peerDependencies": {
- "viem": "2.x"
+ "@tanstack/react-query": "5.x",
+ "react": "18.x",
+ "viem": "2.x",
+ "wagmi": "2.x"
+ },
+ "peerDependenciesMeta": {
+ "@tanstack/react-query": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "wagmi": {
+ "optional": true
+ }
},
"publishConfig": {
"access": "public"
diff --git a/packages/store-sync/src/common.ts b/packages/store-sync/src/common.ts
index 518d9fa434..f4179b70ed 100644
--- a/packages/store-sync/src/common.ts
+++ b/packages/store-sync/src/common.ts
@@ -126,6 +126,8 @@ export type SyncResult = {
waitForTransaction: (tx: Hex) => Promise;
};
+export type SyncAdapter = (opts: SyncOptions) => Promise;
+
// TODO: add optional, original log to this?
export type StorageAdapterLog = Partial & UnionPick;
export type StorageAdapterBlock = { blockNumber: BlockLogs["blockNumber"]; logs: readonly StorageAdapterLog[] };
diff --git a/packages/store-sync/src/exports/internal.ts b/packages/store-sync/src/exports/internal.ts
index 65f0049838..38c719d9ed 100644
--- a/packages/store-sync/src/exports/internal.ts
+++ b/packages/store-sync/src/exports/internal.ts
@@ -1,2 +1,8 @@
+// SQL
export * from "../sql";
-export * from "../stash";
+
+// Stash
+export * from "../stash/common";
+export * from "../stash/createStorageAdapter";
+export * from "../stash/createSyncAdapter";
+export * from "../stash/syncToStash";
diff --git a/packages/store-sync/src/exports/react.ts b/packages/store-sync/src/exports/react.ts
new file mode 100644
index 0000000000..e47b4a61a5
--- /dev/null
+++ b/packages/store-sync/src/exports/react.ts
@@ -0,0 +1,2 @@
+export * from "../react/SyncProvider";
+export * from "../react/useSync";
diff --git a/packages/store-sync/src/react/SyncProvider.tsx b/packages/store-sync/src/react/SyncProvider.tsx
new file mode 100644
index 0000000000..3c5379232e
--- /dev/null
+++ b/packages/store-sync/src/react/SyncProvider.tsx
@@ -0,0 +1,57 @@
+import { ReactNode, createContext, useContext, useEffect } from "react";
+import { useConfig } from "wagmi";
+import { getClient } from "wagmi/actions";
+import { useQuery } from "@tanstack/react-query";
+import { SyncAdapter, SyncOptions, SyncResult } from "../common";
+
+/** @internal */
+export const SyncContext = createContext<{
+ sync?: SyncResult;
+} | null>(null);
+
+export type Props = Omit & {
+ chainId: number;
+ adapter: SyncAdapter;
+ children: ReactNode;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+export function SyncProvider({ chainId, adapter, children, ...syncOptions }: Props) {
+ const existingValue = useContext(SyncContext);
+ if (existingValue != null) {
+ throw new Error("A `SyncProvider` cannot be nested inside another.");
+ }
+
+ const config = useConfig();
+
+ const { data: sync, error: syncError } = useQuery({
+ queryKey: ["sync", chainId],
+ queryFn: async () => {
+ const client = getClient(config, { chainId });
+ if (!client) {
+ throw new Error(`Unable to retrieve Viem client for chain ${chainId}.`);
+ }
+
+ return adapter({ publicClient: client, ...syncOptions });
+ },
+ staleTime: Infinity,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ });
+ if (syncError) throw syncError;
+
+ useEffect(() => {
+ if (!sync) return;
+
+ const sub = sync.storedBlockLogs$.subscribe({
+ error: (error) => console.error("got sync error", error),
+ });
+
+ return (): void => {
+ sub.unsubscribe();
+ };
+ }, [sync]);
+
+ return {children};
+}
diff --git a/packages/store-sync/src/react/useSync.ts b/packages/store-sync/src/react/useSync.ts
new file mode 100644
index 0000000000..fa4e66afc8
--- /dev/null
+++ b/packages/store-sync/src/react/useSync.ts
@@ -0,0 +1,12 @@
+import { useContext } from "react";
+import { SyncContext } from "./SyncProvider";
+import { SyncResult } from "../common";
+
+export function useSync(): Partial {
+ const value = useContext(SyncContext);
+ if (value == null) {
+ throw new Error("`useSync` must be used inside a `SyncProvider`.");
+ }
+ const { sync } = value;
+ return sync ?? {};
+}
diff --git a/packages/store-sync/src/stash/common.ts b/packages/store-sync/src/stash/common.ts
new file mode 100644
index 0000000000..1aee0ceec2
--- /dev/null
+++ b/packages/store-sync/src/stash/common.ts
@@ -0,0 +1,24 @@
+import { defineTable } from "@latticexyz/store/internal";
+import { SyncStep } from "../SyncStep";
+import { getSchemaPrimitives, getValueSchema } from "@latticexyz/protocol-parser/internal";
+
+export const SyncProgress = defineTable({
+ namespaceLabel: "syncToStash",
+ label: "SyncProgress",
+ schema: {
+ step: "string",
+ percentage: "uint32",
+ latestBlockNumber: "uint256",
+ lastBlockNumberProcessed: "uint256",
+ message: "string",
+ },
+ key: [],
+});
+
+export const initialProgress = {
+ step: SyncStep.INITIALIZE,
+ percentage: 0,
+ latestBlockNumber: 0n,
+ lastBlockNumberProcessed: 0n,
+ message: "Connecting",
+} satisfies getSchemaPrimitives>;
diff --git a/packages/store-sync/src/stash/createSyncAdapter.ts b/packages/store-sync/src/stash/createSyncAdapter.ts
new file mode 100644
index 0000000000..b2892f7e10
--- /dev/null
+++ b/packages/store-sync/src/stash/createSyncAdapter.ts
@@ -0,0 +1,30 @@
+import { getRecord, setRecord, registerTable, Stash } from "@latticexyz/stash/internal";
+import { createStorageAdapter } from "./createStorageAdapter";
+import { SyncStep } from "../SyncStep";
+import { SyncAdapter } from "../common";
+import { createStoreSync } from "../createStoreSync";
+import { SyncProgress } from "./common";
+
+export type CreateSyncAdapterOptions = { stash: Stash };
+
+export function createSyncAdapter({ stash }: CreateSyncAdapterOptions): SyncAdapter {
+ return (opts) => {
+ // TODO: clear stash?
+
+ registerTable({ stash, table: SyncProgress });
+
+ const storageAdapter = createStorageAdapter({ stash });
+
+ return createStoreSync({
+ ...opts,
+ storageAdapter,
+ onProgress: (nextValue) => {
+ const currentValue = getRecord({ stash, table: SyncProgress, key: {} });
+ // update sync progress until we're caught up and live
+ if (currentValue?.step !== SyncStep.LIVE) {
+ setRecord({ stash, table: SyncProgress, key: {}, value: nextValue });
+ }
+ },
+ });
+ };
+}
diff --git a/packages/store-sync/src/stash/index.ts b/packages/store-sync/src/stash/index.ts
deleted file mode 100644
index acfaf6b1ad..0000000000
--- a/packages/store-sync/src/stash/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./createStorageAdapter";
-export * from "./syncToStash";
diff --git a/packages/store-sync/src/stash/syncToStash.ts b/packages/store-sync/src/stash/syncToStash.ts
index cfda022373..694ded1443 100644
--- a/packages/store-sync/src/stash/syncToStash.ts
+++ b/packages/store-sync/src/stash/syncToStash.ts
@@ -1,33 +1,8 @@
-import { getRecord, setRecord, registerTable, Stash } from "@latticexyz/stash/internal";
-import { createStorageAdapter } from "./createStorageAdapter";
-import { defineTable } from "@latticexyz/store/internal";
-import { SyncStep } from "../SyncStep";
+import { Stash } from "@latticexyz/stash/internal";
import { SyncOptions, SyncResult } from "../common";
-import { createStoreSync } from "../createStoreSync";
-import { getSchemaPrimitives, getValueSchema } from "@latticexyz/protocol-parser/internal";
+import { createSyncAdapter } from "./createSyncAdapter";
-export const SyncProgress = defineTable({
- namespaceLabel: "syncToStash",
- label: "SyncProgress",
- schema: {
- step: "string",
- percentage: "uint32",
- latestBlockNumber: "uint256",
- lastBlockNumberProcessed: "uint256",
- message: "string",
- },
- key: [],
-});
-
-export const initialProgress = {
- step: SyncStep.INITIALIZE,
- percentage: 0,
- latestBlockNumber: 0n,
- lastBlockNumberProcessed: 0n,
- message: "Connecting",
-} satisfies getSchemaPrimitives>;
-
-export type SyncToStashOptions = Omit & {
+export type SyncToStashOptions = SyncOptions & {
stash: Stash;
startSync?: boolean;
};
@@ -41,21 +16,7 @@ export async function syncToStash({
startSync = true,
...opts
}: SyncToStashOptions): Promise {
- registerTable({ stash, table: SyncProgress });
-
- const storageAdapter = createStorageAdapter({ stash });
-
- const sync = await createStoreSync({
- ...opts,
- storageAdapter,
- onProgress: (nextValue) => {
- const currentValue = getRecord({ stash, table: SyncProgress, key: {} });
- // update sync progress until we're caught up and live
- if (currentValue?.step !== SyncStep.LIVE) {
- setRecord({ stash, table: SyncProgress, key: {}, value: nextValue });
- }
- },
- });
+ const sync = await createSyncAdapter({ stash })(opts);
const sub = startSync ? sync.storedBlockLogs$.subscribe() : null;
function stopSync(): void {
diff --git a/packages/store-sync/tsconfig.json b/packages/store-sync/tsconfig.json
index b4e69ae1f9..004f435e6b 100644
--- a/packages/store-sync/tsconfig.json
+++ b/packages/store-sync/tsconfig.json
@@ -1,6 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
- "outDir": "dist"
+ "outDir": "dist",
+ "jsx": "react-jsx",
+ "jsxImportSource": "react"
}
}
diff --git a/packages/store-sync/tsup.config.ts b/packages/store-sync/tsup.config.ts
index 18b1edd6ea..63c13f2207 100644
--- a/packages/store-sync/tsup.config.ts
+++ b/packages/store-sync/tsup.config.ts
@@ -12,6 +12,7 @@ export default defineConfig((opts) => ({
"src/world/index.ts",
"src/zustand/index.ts",
"src/exports/internal.ts",
+ "src/exports/react.ts",
],
target: "esnext",
format: ["esm"],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 48ef785b8f..406accef36 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1337,18 +1337,42 @@ importers:
specifier: ^4.3.7
version: 4.3.7(react@18.2.0)
devDependencies:
+ '@tanstack/react-query':
+ specifier: ^5.56.2
+ version: 5.56.2(react@18.2.0)
+ '@testing-library/react':
+ specifier: ^16.0.0
+ version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.7)(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@testing-library/react-hooks':
+ specifier: ^8.0.1
+ version: 8.0.1(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@types/debug':
specifier: ^4.1.7
version: 4.1.7
'@types/node':
specifier: 20.12.12
version: 20.12.12
+ '@types/react':
+ specifier: 18.2.22
+ version: 18.2.22
'@types/sql.js':
specifier: ^1.4.4
version: 1.4.4
'@viem/anvil':
specifier: ^0.0.7
version: 0.0.7(bufferutil@4.0.8)(debug@4.3.4)(utf-8-validate@5.0.10)
+ eslint-plugin-react:
+ specifier: 7.31.11
+ version: 7.31.11(eslint@8.57.0)
+ eslint-plugin-react-hooks:
+ specifier: 4.6.0
+ version: 4.6.0(eslint@8.57.0)
+ react:
+ specifier: 18.2.0
+ version: 18.2.0
+ react-dom:
+ specifier: 18.2.0
+ version: 18.2.0(react@18.2.0)
tsup:
specifier: ^6.7.0
version: 6.7.0(postcss@8.5.1)(typescript@5.4.2)
@@ -1358,6 +1382,9 @@ importers:
vitest:
specifier: 0.34.6
version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.34.1)
+ wagmi:
+ specifier: 2.12.11
+ version: 2.12.11(@tanstack/query-core@5.56.2)(@tanstack/react-query@5.56.2(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.25.7)(@babel/preset-env@7.25.3(@babel/core@7.25.7))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@4.30.1)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)
packages/utils:
dependencies:
@@ -8632,10 +8659,6 @@ packages:
is-core-module@2.12.0:
resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==}
- is-core-module@2.15.0:
- resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==}
- engines: {node: '>= 0.4'}
-
is-core-module@2.15.1:
resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==}
engines: {node: '>= 0.4'}
@@ -18249,7 +18272,7 @@ snapshots:
'@testing-library/react-hooks@8.0.1(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
- '@babel/runtime': 7.21.0
+ '@babel/runtime': 7.25.7
react: 18.2.0
react-error-boundary: 3.1.4(react@18.2.0)
optionalDependencies:
@@ -18258,7 +18281,7 @@ snapshots:
'@testing-library/react-hooks@8.0.1(@types/react@18.2.22)(react-test-renderer@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
- '@babel/runtime': 7.21.0
+ '@babel/runtime': 7.25.7
react: 18.2.0
react-error-boundary: 3.1.4(react@18.2.0)
optionalDependencies:
@@ -18267,7 +18290,7 @@ snapshots:
'@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.7)(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
- '@babel/runtime': 7.21.0
+ '@babel/runtime': 7.25.7
'@testing-library/dom': 10.4.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -20945,7 +20968,7 @@ snapshots:
eslint-import-resolver-node@0.3.9:
dependencies:
debug: 3.2.7
- is-core-module: 2.15.0
+ is-core-module: 2.15.1
resolve: 1.22.8
transitivePeerDependencies:
- supports-color
@@ -20959,7 +20982,7 @@ snapshots:
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.5
- is-core-module: 2.15.0
+ is-core-module: 2.15.1
is-glob: 4.0.3
transitivePeerDependencies:
- '@typescript-eslint/parser'
@@ -20990,7 +21013,7 @@ snapshots:
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
- is-core-module: 2.15.0
+ is-core-module: 2.15.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.8
@@ -22114,10 +22137,6 @@ snapshots:
dependencies:
has: 1.0.3
- is-core-module@2.15.0:
- dependencies:
- hasown: 2.0.2
-
is-core-module@2.15.1:
dependencies:
hasown: 2.0.2
@@ -24455,7 +24474,7 @@ snapshots:
react-error-boundary@3.1.4(react@18.2.0):
dependencies:
- '@babel/runtime': 7.21.0
+ '@babel/runtime': 7.25.7
react: 18.2.0
react-error-boundary@4.0.13(react@18.2.0):
@@ -24824,7 +24843,7 @@ snapshots:
resolve@2.0.0-next.5:
dependencies:
- is-core-module: 2.15.0
+ is-core-module: 2.15.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
@@ -26443,6 +26462,43 @@ snapshots:
- utf-8-validate
- zod
+ wagmi@2.12.11(@tanstack/query-core@5.56.2)(@tanstack/react-query@5.56.2(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.25.7)(@babel/preset-env@7.25.3(@babel/core@7.25.7))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@4.30.1)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8):
+ dependencies:
+ '@tanstack/react-query': 5.56.2(react@18.2.0)
+ '@wagmi/connectors': 5.1.10(@types/react@18.2.22)(@wagmi/core@2.13.5(@tanstack/query-core@5.56.2)(@types/react@18.2.22)(react@18.2.0)(typescript@5.4.2)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.25.7)(@babel/preset-env@7.25.3(@babel/core@7.25.7))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@4.30.1)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)
+ '@wagmi/core': 2.13.5(@tanstack/query-core@5.56.2)(@types/react@18.2.22)(react@18.2.0)(typescript@5.4.2)(viem@2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
+ viem: 2.21.19(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8)
+ optionalDependencies:
+ typescript: 5.4.2
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@react-native-async-storage/async-storage'
+ - '@tanstack/query-core'
+ - '@types/react'
+ - '@upstash/redis'
+ - '@vercel/kv'
+ - bufferutil
+ - encoding
+ - immer
+ - ioredis
+ - react-dom
+ - react-native
+ - rollup
+ - supports-color
+ - uWebSockets.js
+ - utf-8-validate
+ - zod
+
walker@1.0.8:
dependencies:
makeerror: 1.0.12