Arkena Docs

Connect Button (React)

A complete connect-disconnect React component with provider detection, mount-restore, and event handling.

This is a complete component you can paste into any Next.js or Vite + React project. It detects the Arkena provider, requests connection, restores on mount for returning users, and reacts to accountsChanged and disconnect events. About 80 lines.

Setup

The component depends on the small helper from Detect the Arkena provider. Drop it in src/lib/arkena.ts:

src/lib/arkena.ts

Code
type ArkenaProvider = {
  request: (args: { method: string; params?: Record<string, unknown> }) => Promise<any>;
  on: (event: string, handler: (...args: any[]) => void) => void;
  removeListener: (event: string, handler: (...args: any[]) => void) => void;
};

declare global {
  interface Window {
    arkena?: ArkenaProvider;
  }
}

export async function getArkenaProvider(timeoutMs = 1000): Promise<ArkenaProvider | null> {
  if (typeof window === "undefined") return null;
  if (window.arkena) return window.arkena;
  return new Promise((resolve) => {
    const onReady = () => resolve(window.arkena ?? null);
    window.addEventListener("arkena#initialized", onReady, { once: true });
    setTimeout(() => resolve(window.arkena ?? null), timeoutMs);
  });
}

The component

src/components/connect-button.tsx

Code
"use client";
import { useEffect, useState } from "react";
import { getArkenaProvider } from "@/lib/arkena";

type State =
  | { status: "loading" }
  | { status: "no-wallet" }
  | { status: "disconnected" }
  | { status: "connected"; account: string };

function shorten(party: string) {
  if (party.length <= 18) return party;
  return party.slice(0, 8) + "" + party.slice(-6);
}

export function ConnectButton() {
  const [state, setState] = useState<State>({ status: "loading" });

  useEffect(() => {
    let mounted = true;

    async function init() {
      const provider = await getArkenaProvider();
      if (!provider || !mounted) {
        if (mounted) setState({ status: "no-wallet" });
        return;
      }

      // Try a silent connect — if the origin is already trusted, no popup.
      // If not trusted yet, this surfaces the approval popup; gate behind a
      // user gesture instead if you'd rather defer that.
      try {
        const { accounts } = await provider.request({ method: "canton_connect" });
        if (mounted) setState({ status: "connected", account: accounts[0] });
      } catch (err) {
        if (mounted) setState({ status: "disconnected" });
      }

      const onAccounts = (accounts: string[]) => {
        if (!mounted) return;
        if (accounts.length === 0) setState({ status: "disconnected" });
        else setState({ status: "connected", account: accounts[0] });
      };
      const onDisconnect = () => {
        if (mounted) setState({ status: "disconnected" });
      };
      provider.on("accountsChanged", onAccounts);
      provider.on("disconnect", onDisconnect);
      return () => {
        provider.removeListener("accountsChanged", onAccounts);
        provider.removeListener("disconnect", onDisconnect);
      };
    }

    void init();
    return () => {
      mounted = false;
    };
  }, []);

  if (state.status === "loading") return <button disabled></button>;
  if (state.status === "no-wallet") {
    return (
      <a href="https://arkena.io/install" target="_blank" rel="noreferrer">
        Install Arkena
      </a>
    );
  }
  if (state.status === "disconnected") {
    return (
      <button
        onClick={async () => {
          const provider = await getArkenaProvider();
          if (!provider) return;
          try {
            const { accounts } = await provider.request({ method: "canton_connect" });
            setState({ status: "connected", account: accounts[0] });
          } catch {
            // 4001 — user rejected. Stay disconnected; don't auto-retry.
          }
        }}
      >
        Connect wallet
      </button>
    );
  }
  return (
    <div>
      <span>Connected as {shorten(state.account)}</span>
      <button
        onClick={async () => {
          const provider = await getArkenaProvider();
          await provider?.request({ method: "canton_disconnect" });
          setState({ status: "disconnected" });
        }}
      >
        Disconnect
      </button>
    </div>
  );
}

What's interesting in this code

Three things worth noting if you're going to extend it:

The mounted flag. Guards against setState after unmount, which React strict-mode catches. Standard pattern for any async-effect cleanup.

The silent canton_connect on mount. The wallet remembers approved origins; calling canton_connect on a trusted origin resolves without a popup. This is what makes returning users skip the prompt. There's no separate "is this connected?" call — just try, catch on 4001.

The empty-accounts case in onAccountsChanged. Some wallet implementations emit an empty array as a soft-disconnect signal (the user removed all accounts from this origin). Handle it the same as a hard disconnect.

What's next