diff --git a/.changeset/friendly-fireants-itch.md b/.changeset/friendly-fireants-itch.md
new file mode 100644
index 00000000..f8da0058
--- /dev/null
+++ b/.changeset/friendly-fireants-itch.md
@@ -0,0 +1,8 @@
+---
+"@reactive-dot/react": minor
+---
+
+Add `useChainId` hook.
+
+- Get current chain ID from context
+- Optionally assert current chain ID using allowlist and/or denylist
diff --git a/apps/docs/docs/getting-started/multichain.md b/apps/docs/docs/getting-started/multichain.md
index bd374a78..3185a0f2 100644
--- a/apps/docs/docs/getting-started/multichain.md
+++ b/apps/docs/docs/getting-started/multichain.md
@@ -118,3 +118,73 @@ function Component() {
const westendBlock = useBlock({ chainId: "westend" });
}
```
+
+## Chain narrowing
+
+By default, Reactive DOT provides type definitions based on the merged definitions of all chains in the config. For example, if your DApp is set up to be used with Polkadot, Kusama, and Westend, the following code will not work because the Bounties pallet only exists on Polkadot and Kusama, not on Westend:
+
+```tsx
+function Component() {
+ // Since `Bounties` pallet doesn't exist on Westend, this will:
+ // 1. Raise a TypeScript error
+ // 2. Throw an error during runtime if Westend is selected
+ const bountyCount = useLazyLoadQuery((builder) =>
+ builder.readStorage("Bounties", "BountyCount", []),
+ );
+
+ // ...
+}
+```
+
+You have the option of either explicitly specifying the chain to query, which will override the chain ID provided via context:
+
+```tsx
+function Component() {
+ const bountyCount = useLazyLoadQuery(
+ (builder) => builder.readStorage("Bounties", "BountyCount", []),
+ { chainId: "polkadot" },
+ );
+
+ // ...
+}
+```
+
+Or, to continue using the chain ID provided via context, you can use the [`useChainId`](/api/react/function/useChainId) hook along with its allowlist/denylist functionality:
+
+```tsx
+function BountiesPalletRequiredComponent() {
+ const bountyCount = useLazyLoadQuery(
+ (builder) => builder.readStorage("Bounties", "BountyCount", []),
+ {
+ // `useChainId` with the allow/deny list will:
+ // 1. Throw an error if the context's chain ID conflicts with the list(s)
+ // 2. Restrict descriptors used by `useLazyLoadQuery` to provide correct intellisense
+ chainId: useChainId({
+ allowlist: ["polkadot", "kusama"],
+ // Or
+ denylist: ["westend"],
+ }),
+ },
+ );
+
+ // ...
+}
+
+function App() {
+ // ...
+
+ // Only use compatible chain IDs, else an error will be thrown
+ const bountiesEnabledChainIds = ["polkadot", "kusama"] satisfies ChainId[];
+
+ return (
+
+ {bountiesEnabledChainIds.map((chainId) => (
+
+
+
+ ))}
+ {/* ... */}
+
+ );
+}
+```
diff --git a/packages/react/src/hooks/use-accounts.ts b/packages/react/src/hooks/use-accounts.ts
index 1b668c5b..6ad6c8a8 100644
--- a/packages/react/src/hooks/use-accounts.ts
+++ b/packages/react/src/hooks/use-accounts.ts
@@ -1,6 +1,6 @@
import { accountsAtom } from "../stores/accounts.js";
import type { ChainHookOptions } from "./types.js";
-import { useChainId } from "./use-chain-id.js";
+import { useChainId_INTERNAL } from "./use-chain-id.js";
import { useAtomValue } from "jotai";
/**
@@ -10,5 +10,5 @@ import { useAtomValue } from "jotai";
* @returns The currently connected accounts
*/
export function useAccounts(options?: ChainHookOptions) {
- return useAtomValue(accountsAtom(useChainId(options)));
+ return useAtomValue(accountsAtom(useChainId_INTERNAL(options)));
}
diff --git a/packages/react/src/hooks/use-block.ts b/packages/react/src/hooks/use-block.ts
index 6d9b3434..ad17914f 100644
--- a/packages/react/src/hooks/use-block.ts
+++ b/packages/react/src/hooks/use-block.ts
@@ -3,7 +3,7 @@ import {
finalizedBlockAtomFamily,
} from "../stores/block.js";
import type { ChainHookOptions } from "./types.js";
-import { useChainId } from "./use-chain-id.js";
+import { useChainId_INTERNAL } from "./use-chain-id.js";
import { useAtomValue } from "jotai";
/**
@@ -17,7 +17,7 @@ export function useBlock(
tag: "best" | "finalized" = "finalized",
options?: ChainHookOptions,
) {
- const chainId = useChainId(options);
+ const chainId = useChainId_INTERNAL(options);
return useAtomValue(
tag === "finalized"
diff --git a/packages/react/src/hooks/use-chain-id.ts b/packages/react/src/hooks/use-chain-id.ts
index ed65af50..db4aeace 100644
--- a/packages/react/src/hooks/use-chain-id.ts
+++ b/packages/react/src/hooks/use-chain-id.ts
@@ -1,14 +1,44 @@
import { ChainIdContext } from "../contexts/index.js";
import type { ChainHookOptions } from "./types.js";
-import { ReDotError } from "@reactive-dot/core";
+import { type ChainId, ReDotError } from "@reactive-dot/core";
import { useContext } from "react";
-export function useChainId(options?: ChainHookOptions) {
+/**
+ * Hook for getting the current chain ID.
+ *
+ * @param options - Additional options
+ * @returns
+ */
+export function useChainId<
+ const TAllowList extends ChainId[],
+ const TDenylist extends ChainId[] = [],
+>(options?: { allowlist?: TAllowList; denylist?: TDenylist }) {
+ const chainId = useContext(ChainIdContext);
+
+ if (chainId === undefined) {
+ throw new ReDotError("No chain ID provided");
+ }
+
+ if (options?.allowlist?.includes(chainId) === false) {
+ throw new ReDotError("Chain ID not allowed", { cause: chainId });
+ }
+
+ if (options?.denylist?.includes(chainId)) {
+ throw new ReDotError("Chain ID denied", { cause: chainId });
+ }
+
+ return chainId as Exclude<
+ Extract,
+ TDenylist[number]
+ >;
+}
+
+export function useChainId_INTERNAL(options?: ChainHookOptions) {
const contextChainId = useContext(ChainIdContext);
const chainId = options?.chainId ?? contextChainId;
if (chainId === undefined) {
- throw new ReDotError("No chain Id provided");
+ throw new ReDotError("No chain ID provided");
}
return chainId;
diff --git a/packages/react/src/hooks/use-chain-spec-data.ts b/packages/react/src/hooks/use-chain-spec-data.ts
index 8b070a17..3c90efd8 100644
--- a/packages/react/src/hooks/use-chain-spec-data.ts
+++ b/packages/react/src/hooks/use-chain-spec-data.ts
@@ -1,6 +1,6 @@
import { chainSpecDataAtomFamily } from "../stores/client.js";
import type { ChainHookOptions } from "./types.js";
-import { useChainId } from "./use-chain-id.js";
+import { useChainId_INTERNAL } from "./use-chain-id.js";
import { useAtomValue } from "jotai";
/**
@@ -10,5 +10,5 @@ import { useAtomValue } from "jotai";
* @returns The [JSON-RPC spec](https://paritytech.github.io/json-rpc-interface-spec/api/chainSpec.html)
*/
export function useChainSpecData(options?: ChainHookOptions) {
- return useAtomValue(chainSpecDataAtomFamily(useChainId(options)));
+ return useAtomValue(chainSpecDataAtomFamily(useChainId_INTERNAL(options)));
}
diff --git a/packages/react/src/hooks/use-client.ts b/packages/react/src/hooks/use-client.ts
index 2f60e3c8..f0563c16 100644
--- a/packages/react/src/hooks/use-client.ts
+++ b/packages/react/src/hooks/use-client.ts
@@ -1,6 +1,6 @@
import { clientAtomFamily } from "../stores/client.js";
import type { ChainHookOptions } from "./types.js";
-import { useChainId } from "./use-chain-id.js";
+import { useChainId_INTERNAL } from "./use-chain-id.js";
import { useAtomValue } from "jotai";
/**
@@ -10,5 +10,5 @@ import { useAtomValue } from "jotai";
* @returns Polkadot-API client
*/
export function useClient(options?: ChainHookOptions) {
- return useAtomValue(clientAtomFamily(useChainId(options)));
+ return useAtomValue(clientAtomFamily(useChainId_INTERNAL(options)));
}
diff --git a/packages/react/src/hooks/use-mutation.ts b/packages/react/src/hooks/use-mutation.ts
index 682e26aa..26aec413 100644
--- a/packages/react/src/hooks/use-mutation.ts
+++ b/packages/react/src/hooks/use-mutation.ts
@@ -6,7 +6,7 @@ import {
import { typedApiAtomFamily } from "../stores/client.js";
import type { ChainHookOptions } from "./types.js";
import { useAsyncState } from "./use-async-state.js";
-import { useChainId } from "./use-chain-id.js";
+import { useChainId_INTERNAL } from "./use-chain-id.js";
import type { ChainId, Chains } from "@reactive-dot/core";
import { MutationError, PENDING } from "@reactive-dot/core";
import { useAtomCallback } from "jotai/utils";
@@ -54,7 +54,7 @@ export function useMutation<
txOptions?: TxOptions>;
},
) {
- const chainId = useChainId(options);
+ const chainId = useChainId_INTERNAL(options);
const mutationEventSubject = useContext(MutationEventSubjectContext);
const contextSigner = useContext(SignerContext);
diff --git a/packages/react/src/hooks/use-query.ts b/packages/react/src/hooks/use-query.ts
index f319c941..506a2f03 100644
--- a/packages/react/src/hooks/use-query.ts
+++ b/packages/react/src/hooks/use-query.ts
@@ -5,7 +5,7 @@ import {
import type { Falsy, FalsyGuard, FlatHead } from "../types.js";
import { flatHead, stringify } from "../utils/vanilla.js";
import type { ChainHookOptions } from "./types.js";
-import { useChainId } from "./use-chain-id.js";
+import { useChainId_INTERNAL } from "./use-chain-id.js";
import {
IDLE,
Query,
@@ -37,7 +37,7 @@ export function useQueryRefresher<
: Chains[TChainId],
TChainId extends ChainId,
>(builder: TQuery, options?: ChainHookOptions) {
- const chainId = useChainId(options);
+ const chainId = useChainId_INTERNAL(options);
const refresh = useAtomCallback(
useCallback(
@@ -99,7 +99,7 @@ export function useLazyLoadQueryWithRefresh<
>,
refresh: () => void,
] {
- const chainId = useChainId(options);
+ const chainId = useChainId_INTERNAL(options);
const query = useMemo(
() => (!builder ? undefined : builder(new Query([]))),
diff --git a/packages/react/src/hooks/use-typed-api.ts b/packages/react/src/hooks/use-typed-api.ts
index 64d9d3b5..ef47233f 100644
--- a/packages/react/src/hooks/use-typed-api.ts
+++ b/packages/react/src/hooks/use-typed-api.ts
@@ -1,6 +1,6 @@
import { typedApiAtomFamily } from "../stores/client.js";
import type { ChainHookOptions } from "./types.js";
-import { useChainId } from "./use-chain-id.js";
+import { useChainId_INTERNAL } from "./use-chain-id.js";
import type { ChainId, Chains } from "@reactive-dot/core";
import { useAtomValue } from "jotai";
import type { TypedApi } from "polkadot-api";
@@ -14,5 +14,5 @@ import type { TypedApi } from "polkadot-api";
export function useTypedApi(
options?: ChainHookOptions,
): TypedApi {
- return useAtomValue(typedApiAtomFamily(useChainId(options)));
+ return useAtomValue(typedApiAtomFamily(useChainId_INTERNAL(options)));
}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index fcc8412f..41e2f2ad 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -9,12 +9,13 @@ export {
export type { ChainHookOptions } from "./hooks/types.js";
export { useAccounts } from "./hooks/use-accounts.js";
export { useBlock } from "./hooks/use-block.js";
+export { useChainId } from "./hooks/use-chain-id.js";
export { useChainSpecData } from "./hooks/use-chain-spec-data.js";
export { useClient } from "./hooks/use-client.js";
export { useConnectWallet } from "./hooks/use-connect-wallet.js";
export { useDisconnectWallet } from "./hooks/use-disconnect-wallet.js";
-export { useMutation } from "./hooks/use-mutation.js";
export { useMutationEffect } from "./hooks/use-mutation-effect.js";
+export { useMutation } from "./hooks/use-mutation.js";
export {
useLazyLoadQuery,
useLazyLoadQueryWithRefresh,