Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/four-pots-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@solana/react': minor
---

Add `useAction` — a React hook that bridges any async function into a tracked action with `dispatch` / `status` / `data` / `error` / `reset` and supersede-on-second-call semantics. Built on `createReactiveActionStore` from `@solana/subscribable`.

The wrapped function receives a fresh `AbortSignal` per `send(...)`. Calling `dispatch` again while a prior call is in flight aborts the first; awaiters of the superseded call see a rejection with an `AbortError` filterable via `isAbortError` from `@solana/promises`. `data` from a prior `success` persists through subsequent `running` states for stale-while-revalidate UX; only `reset()` clears it.

`fn` is held in a ref synced to the latest render's closure, so values it captures (form state, route params, etc.) are always fresh on each new `send(...)` without the caller needing to maintain a `deps` array. In-flight calls are unaffected — they continue with the closure they captured at dispatch time. Matches the convention used by `useMutation` in TanStack Query and `useWriteContract` in wagmi.

The shared `ActionResult<TArgs, TResult>` type is also exported so plugin hooks can declare their return shape against it.
37 changes: 37 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,43 @@ function useRpc() {

Pass an array of capability names when a hook needs more than one (e.g. `['rpc', 'rpcSubscriptions']`) — the same `providerHint` is surfaced for whichever is missing.

### `useAction(fn)`

Bridges any async function into a tracked action with `dispatch` / `status` / `data` / `error` / `reset`. Each `dispatch(...)` runs `fn` with a fresh `AbortSignal` and tracks the lifecycle through React state; calling `dispatch` again while a prior call is in flight aborts the first.

`fn` is held in a ref that always points at the latest closure — there is no `deps` array to maintain. Each `dispatch(...)` invokes the most recently rendered `fn`, so values captured inside (e.g. form state, route params) are always fresh. In-flight calls are unaffected — they continue with the closure they captured at dispatch time.

```tsx
import { useAction } from '@solana/react';
import { isAbortError } from '@solana/promises';

function PostMessageButton({ url, body }: { url: string; body: string }) {
const { dispatch, isRunning, error } = useAction(async (signal, content: string) => {
const res = await fetch(url, { body: content, method: 'POST', signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});

return (
<button disabled={isRunning} onClick={() => dispatch(body)}>
{isRunning ? 'Posting…' : error ? 'Retry' : 'Post'}
</button>
);
}
```

`dispatch` returns `Promise<TResult>`. Fire-and-forget callers can ignore it and render from `status` / `data` / `error`. Awaiters that read the resolved value (e.g. to navigate on success) should filter superseded calls with `isAbortError` from `@solana/promises`:

```tsx
try {
const { id } = await dispatch(body);
navigate(`/messages/${id}`);
} catch (err) {
if (isAbortError(err)) return; // superseded — state already reflects the newer call
// handle real error
}
```

## Hooks

### `useSignIn(uiWalletAccount, chain)`
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"@solana/plugin-core": "workspace:*",
"@solana/promises": "workspace:*",
"@solana/signers": "workspace:*",
"@solana/subscribable": "workspace:*",
"@solana/transaction-messages": "workspace:*",
"@solana/transactions": "workspace:*",
"@solana/wallet-standard-features": "^1.3.0",
Expand Down
135 changes: 135 additions & 0 deletions packages/react/src/__tests__/useAction-test.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { isAbortError } from '@solana/promises';
import { act } from '@testing-library/react';

import { renderHook } from '../__test-utils__/render';

import { useAction } from '../useAction';

describe('useAction', () => {
it('transitions idle → running → success', async () => {
const { promise, resolve } = Promise.withResolvers<string>();
const fn = jest.fn((_s: AbortSignal, _arg: string) => promise);
const { result } = renderHook(() => useAction(fn));

expect(result.current.status).toBe('idle');
expect(result.current.data).toBeUndefined();

act(() => {
void result.current.dispatch('hello');
});
expect(result.current.status).toBe('running');
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal), 'hello');

await act(async () => resolve('world'));
expect(result.current.status).toBe('success');
expect(result.current.data).toBe('world');
});

it('transitions idle → running → error', async () => {
const boom = new Error('boom');
const { promise, reject } = Promise.withResolvers<string>();
const { result } = renderHook(() => useAction((_s: AbortSignal) => promise));

act(() => {
result.current.dispatch().catch(() => {});
});
expect(result.current.status).toBe('running');

await act(async () => reject(boom));
expect(result.current.status).toBe('error');
expect(result.current.error).toBe(boom);
});

it('aborts the prior call when dispatch is invoked again while one is in flight', async () => {
const fn = jest.fn((signal: AbortSignal) => {
const { promise, reject } = Promise.withResolvers<string>();
signal.addEventListener('abort', () => reject(signal.reason));
return promise;
});
const { result } = renderHook(() => useAction(fn));

let firstCall!: Promise<string>;
act(() => {
firstCall = result.current.dispatch();
});
// Pre-attach a no-op catch so the second call's eventual abort rejection (when the hook
// unmounts) doesn't surface as an unhandled rejection.
act(() => {
result.current.dispatch().catch(() => {});
});

expect(fn.mock.calls[0][0].aborted).toBe(true);
expect(fn.mock.calls[1][0].aborted).toBe(false);

const firstError = await firstCall.catch((err: unknown) => err);
expect(isAbortError(firstError)).toBe(true);
});

it('await dispatch(...) resolves to the function result on success', async () => {
const { result } = renderHook(() => useAction(async (_s: AbortSignal, n: number) => n * 2));
await act(async () => {
await expect(result.current.dispatch(21)).resolves.toBe(42);
});
expect(result.current.data).toBe(42);
});

it('reset() returns to idle and clears data', async () => {
const { result } = renderHook(() => useAction(async () => 'hi'));
await act(async () => {
await result.current.dispatch();
});
expect(result.current.data).toBe('hi');

act(() => result.current.reset());
expect(result.current.status).toBe('idle');
expect(result.current.data).toBeUndefined();
});

it('keeps prior data through a subsequent running state (stale-while-revalidate)', async () => {
const { promise: secondPending, resolve: resolveSecond } = Promise.withResolvers<string>();
let n = 0;
const fn = () => (++n === 1 ? Promise.resolve('first') : secondPending);
const { result } = renderHook(() => useAction(fn));

await act(async () => {
await result.current.dispatch();
});
expect(result.current.data).toBe('first');

act(() => {
void result.current.dispatch();
});
expect(result.current.status).toBe('running');
expect(result.current.data).toBe('first'); // stale data preserved during revalidation

await act(async () => resolveSecond('second'));
expect(result.current.data).toBe('second');
});

it('uses the latest fn closure on each new call', async () => {
let captured: number | null = null;
const { result, rerender } = renderHook(({ value }: { value: number }) => useAction(async () => (captured = value)), { initialProps: { value: 1 } });

await act(async () => {
await result.current.dispatch();
});
expect(captured).toBe(1);

rerender({ value: 2 });
await act(async () => {
await result.current.dispatch();
});
expect(captured).toBe(2);
});

it('keeps stable dispatch / reset references across re-renders even as fn changes', () => {
const { result, rerender } = renderHook(({ tag }: { tag: string }) => useAction(async () => tag), {
initialProps: { tag: 'a' },
});
const { dispatch, reset } = result.current;

rerender({ tag: 'b' });
expect(result.current.dispatch).toBe(dispatch);
expect(result.current.reset).toBe(reset);
});
});
42 changes: 42 additions & 0 deletions packages/react/src/__typetests__/useAction-typetest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable react-hooks/rules-of-hooks */

import { ActionResult, useAction } from '../useAction';

// [DESCRIBE] useAction
{
// It infers TArgs and TResult from the wrapped function
{
const result = useAction(async (_signal: AbortSignal, value: number) => `n=${value}`);
result satisfies ActionResult<[value: number], string>;
result.dispatch(7) satisfies Promise<string>;
result.data satisfies string | undefined;
}

// The status field is a discriminated string union, not a generic string
{
const fn = (): Promise<number> => Promise.resolve(1);
const { status } = useAction(fn);
status satisfies 'error' | 'idle' | 'running' | 'success';
// @ts-expect-error - 'pending' is not a valid status
status satisfies 'pending';
}

// dispatch rejects calls that pass the wrong argument types
{
const { dispatch } = useAction(async (_signal: AbortSignal, _value: number) => 0);
dispatch(1);
// @ts-expect-error - argument should be a number
dispatch('not a number');
}

// Zero-argument actions get a zero-argument dispatch
{
const fn = (): Promise<string> => Promise.resolve('ok');
const { dispatch } = useAction(fn);
dispatch() satisfies Promise<string>;
// @ts-expect-error - dispatch takes no arguments
dispatch('extra');
}
}
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* @packageDocumentation
*/
export * from './ClientProvider';
export * from './useAction';
export * from './useClient';
export * from './useClientCapability';
export * from './useSignAndSendTransaction';
Expand Down
130 changes: 130 additions & 0 deletions packages/react/src/useAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { createReactiveActionStore } from '@solana/subscribable';
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';

// `useLayoutEffect` warns on the server. The ref-sync only needs to be in place by the time an
// event handler can fire, which can't happen during SSR — so on the server, plain `useEffect`
// is functionally equivalent and silent.
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

/**
* Reactive state and controls for an async action managed by {@link useAction}
* (and plugin-specific hooks built on top of it).
*
* Lifecycle: starts at `idle`. Each `dispatch(...)` flips to `running`, then to `success` or
* `error` depending on the outcome. `data` from a prior `success` persists through subsequent
* `running` states for stale-while-revalidate UX; only `reset()` clears it.
*
* Calling `dispatch(...)` while a previous call is in flight aborts the first via its
* `AbortSignal` and replaces it. Awaiters of the superseded call see a rejection with an
* `AbortError`, filterable via `isAbortError` from `@solana/promises`.
*
* @typeParam TArgs - The argument tuple `dispatch` accepts; forwarded to the wrapped function
* after the abort signal.
* @typeParam TResult - The value the wrapped function resolves to on success.
*/
export type ActionResult<TArgs extends readonly unknown[], TResult> = {
/** The result on success, or `undefined` if no successful call has happened yet. */
data: TResult | undefined;
/**
* Trigger the action. Resolves with the wrapped function's result, or rejects with the thrown
* error. Calling `dispatch` again while a prior call is in flight aborts the first and rejects
* its promise with an `AbortError`. Stable reference.
*
* Mirrors `ReactiveActionStore.dispatchAsync` — combined into a single function on the hook
* because React event handlers typically don't await the result. Fire-and-forget callers can
* ignore the returned promise and render from `status` / `data` / `error`. Awaiters that read
* the resolved value (e.g. to navigate on success) should filter supersede rejections with
* `isAbortError` from `@solana/promises`.
*/
dispatch: (...args: TArgs) => Promise<TResult>;
/** The error on failure, or `undefined`. */
error: unknown;
/** `true` when `status === 'error'`. */
isError: boolean;
/** `true` when `status === 'idle'`. */
isIdle: boolean;
/** `true` when `status === 'running'` — a dispatch is in flight. */
isRunning: boolean;
/** `true` when `status === 'success'`. */
isSuccess: boolean;
/** Reset state back to `idle`, aborting any in-flight call. Stable reference. */
reset: () => void;
/**
* The current lifecycle status as a discriminated string. The `isIdle` / `isRunning` /
* `isSuccess` / `isError` booleans below are derived from this — pick whichever reads better
* at the call site.
*/
status: 'error' | 'idle' | 'running' | 'success';
};

/**
* Bridge an arbitrary async function into a reactive {@link ActionResult}. Each `dispatch(...)`
* call runs the function with a fresh {@link AbortSignal} and tracks its lifecycle through React
* state; a second call while a first is in flight aborts the first.
*
* `fn` is held in a ref that always points at the latest closure — there is no `deps` array to
* maintain. Each `dispatch(...)` invokes the most recently rendered `fn`, so values captured
* inside (e.g. form state, route params) are always fresh without explicit dependency tracking.
* In-flight calls are unaffected — they continue with the closure they captured at dispatch time.
*
* @typeParam TArgs - The argument tuple `dispatch` accepts; forwarded to `fn` after the abort
* signal.
* @typeParam TResult - The value `fn` resolves to on success.
*
* @example
* ```tsx
* import { useAction } from '@solana/react';
*
* function PostMessageButton({ url, body }: { url: string; body: string }) {
* const { dispatch, isRunning, error } = useAction(async (signal, content: string) => {
* const res = await fetch(url, { body: content, method: 'POST', signal });
* if (!res.ok) throw new Error(`HTTP ${res.status}`);
* return res.json() as Promise<{ id: string }>;
* });
* return (
* <button disabled={isRunning} onClick={() => dispatch(body)}>
* {isRunning ? 'Posting…' : error ? 'Retry' : 'Post'}
* </button>
* );
* }
* ```
*
* @see {@link ActionResult}
*/
export function useAction<TArgs extends readonly unknown[], TResult>(
fn: (signal: AbortSignal, ...args: TArgs) => Promise<TResult>,
): ActionResult<TArgs, TResult> {
// Stable callback over the latest closure. Similar to `useEffectEvent`, but we need to
// pass the callback to `createReactiveActionStore` so need to implement the pattern manually.
const fnRef = useRef(fn);
useIsomorphicLayoutEffect(() => {
fnRef.current = fn;
});

// `createReactiveActionStore` only reads the callback when the returned `dispatch` is
// called, not during render. The `react-hooks/refs` rule doesn't know that, so we silence it.
// eslint-disable-next-line react-hooks/refs
const [store] = useState(() =>
createReactiveActionStore<TArgs, TResult>((signal, ...args) => fnRef.current(signal, ...args)),
);

// Reset on unmount so any in-flight call is aborted and state is dropped.
useEffect(() => () => store.reset(), [store]);

Comment thread
mcintyre94 marked this conversation as resolved.
const state = useSyncExternalStore(store.subscribe, store.getState);

return useMemo(
() => ({
data: state.data,
dispatch: store.dispatchAsync,
error: state.error,
isError: state.status === 'error',
isIdle: state.status === 'idle',
isRunning: state.status === 'running',
isSuccess: state.status === 'success',
reset: store.reset,
status: state.status,
}),
[state, store],
);
}
2 changes: 1 addition & 1 deletion packages/react/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"jsx": "react",
"lib": ["DOM", "ES2015", "ES2022.Object", "ESNext.Promise"]
"lib": ["DOM", "ES2015", "ES2022.Object", "ES2024.Promise"]
},
"display": "@solana/react",
"extends": "../tsconfig/base.json",
Expand Down
Loading
Loading