Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .changeset/fresh-eggs-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@solana/react': minor
---

Add `useRequest` — a React hook for one-shot RPC (or similar) reads. Pass a memoized `ReactiveActionSource<T>` (satisfied by `PendingRpcRequest`) and the hook fires the request on mount, re-fires whenever the source identity changes, and aborts the in-flight call on cleanup.

```tsx
const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]);
const { data, error, refresh } = useRequest(source);
```

The result reports `status` as one of `loading | loaded | error | retrying | disabled`. After a prior success, calling `refresh()` keeps `data` populated and reports `status: 'retrying'` so UIs can show stale content while revalidating instead of flashing to blank. Pass `null` for the source to gate the request off — useful while inputs aren't yet known. The result then reports `status: 'disabled'`.

Optional `perRequestSignal: () => AbortSignal` is a factory invoked on every attempt (initial fire + every `refresh()`). The returned signal is passed through to the underlying `.reactiveStore({ perRequestSignal })`. The natural use is per-attempt timeouts: `perRequestSignal: () => AbortSignal.timeout(5_000)` gives every attempt its own 5-second clock that resets on refresh. The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed.

The new `RequestResult<T>` and `UseRequestOptions` types are exported alongside the hook so plugin hooks built on top can declare their return shape against them.
53 changes: 53 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,59 @@ try {
}
```

### `useRequest(source, options?)`

Fires a one-shot request on mount and re-fires whenever `source` changes identity. Returns `{ data, error, status, isLoading, refresh }`. Use it for RPC reads that don't have a subscription counterpart (`getLatestBlockhash`, `getBalance`, `getEpochInfo`, …) or for one-shot reads of values you'd otherwise subscribe to.

`source` is any `{ reactiveStore(): ReactiveActionStore<[], T> }` — `PendingRpcRequest` is the canonical implementation. Memoize it with `useMemo` keyed on the inputs it depends on. Pass `null` to disable (the result reports `status: 'disabled'`).

```tsx
import { useClientCapability, useRequest } from '@solana/react';
import type { ClientWithRpc, GetLatestBlockhashApi } from '@solana/kit';

function LatestBlockhash() {
const client = useClientCapability<ClientWithRpc<GetLatestBlockhashApi>>({
capability: 'rpc',
hookName: 'useLatestBlockhash',
providerHint: 'Install `solanaRpc()` on the client.',
});
const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]);
const { data, error, refresh } = useRequest(source);
if (error) return <button onClick={refresh}>Retry</button>;
return <p>{data ? `Blockhash: ${data.value.blockhash}` : 'Loading…'}</p>;
}
```

`refresh()` re-fires the request manually — useful for a "Retry" button on an error state, or a user-initiated reload. After a prior success, a `refresh()` keeps the old `data` populated and reports `status: 'retrying'` so UIs can show stale content while revalidating.

```tsx
function Balance({ address }: { address: Address | null }) {
const client = useClientCapability<ClientWithRpc<GetBalanceApi>>({
capability: 'rpc',
hookName: 'useBalance',
providerHint: 'Install `solanaRpc()` on the client.',
});
// Disabled until an address is selected.
const source = useMemo(() => (address ? client.rpc.getBalance(address) : null), [client, address]);
const { data, status } = useRequest(source);
if (status === 'disabled') return <p>Select an account to see its balance.</p>;
return <p>{data?.value !== undefined ? `${data.value} lamports` : 'Loading…'}</p>;
}
```

#### Per-attempt cancellation

Pass `perRequestSignal` to attach a cancellation signal to each individual attempt — initial fire plus every `refresh()`. The natural use is per-attempt timeouts:

```tsx
const { data, error, refresh } = useRequest(source, {
// Each attempt gets a fresh 5-second clock. `refresh()` resets it.
perRequestSignal: () => AbortSignal.timeout(5_000),
});
```

The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. To kill the hook entirely (e.g. on a route change), do it the React-native way: set the memoized source to `null` (the result reports `disabled`), or let the component unmount.

## Hooks

### `useSignIn(uiWalletAccount, chain)`
Expand Down
192 changes: 192 additions & 0 deletions packages/react/src/__tests__/useRequest-test.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { createReactiveActionStore, ReactiveActionSource } from '@solana/subscribable';
import { act, renderHook } from '@testing-library/react';

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

function makeFakeRequest<T>(): {
fn: jest.Mock<Promise<T>, [AbortSignal]>;
rejectLatest: (err: unknown) => void;
resolveLatest: (value: T) => void;
source: ReactiveActionSource<T>;
} {
let latest: PromiseWithResolvers<T> | null = null;
const fn = jest.fn<Promise<T>, [AbortSignal]>(() => {
latest = Promise.withResolvers<T>();
return latest.promise;
});
return {
fn,
rejectLatest(err) {
latest!.reject(err);
},
resolveLatest(value) {
latest!.resolve(value);
},
source: {
reactiveStore(options) {
const store = createReactiveActionStore<[], T>(fn, options);
store.dispatch();
return store;
},
},
};
}

describe('useRequest', () => {
it('auto-dispatches on mount and transitions loading → loaded', async () => {
const req = makeFakeRequest<string>();
const { result } = renderHook(() => useRequest(req.source));

expect(result.current.status).toBe('loading');
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
expect(req.fn).toHaveBeenCalledTimes(1);

await act(async () => req.resolveLatest('hi'));
expect(result.current.status).toBe('loaded');
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBe('hi');
});

it('reports error status with the error value when the call rejects', async () => {
const boom = new Error('boom');
const req = makeFakeRequest<string>();
const { result } = renderHook(() => useRequest(req.source));

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

it('refresh() re-dispatches and transitions retrying → loaded while preserving stale data', async () => {
const req = makeFakeRequest<string>();
const { result } = renderHook(() => useRequest(req.source));

await act(async () => req.resolveLatest('first'));
expect(result.current.data).toBe('first');
expect(req.fn).toHaveBeenCalledTimes(1);

act(() => result.current.refresh());
expect(req.fn).toHaveBeenCalledTimes(2);
expect(result.current.status).toBe('retrying');
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBe('first');

await act(async () => req.resolveLatest('second'));
expect(result.current.status).toBe('loaded');
expect(result.current.data).toBe('second');
});

it('rebuilds the store and fires a fresh request when the source identity changes', () => {
const reqA = makeFakeRequest<string>();
const reqB = makeFakeRequest<string>();
const { rerender } = renderHook(
({ which }: { which: 'a' | 'b' }) => useRequest(which === 'a' ? reqA.source : reqB.source),
{ initialProps: { which: 'a' } },
);
expect(reqA.fn).toHaveBeenCalledTimes(1);
expect(reqB.fn).not.toHaveBeenCalled();

rerender({ which: 'b' });
expect(reqB.fn).toHaveBeenCalledTimes(1);
});

it('reports status: disabled when the source is null', () => {
const { result } = renderHook(() => useRequest<string>(null));
expect(result.current.status).toBe('disabled');
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
});

it('starts firing when the source transitions from null to a real source', () => {
const req = makeFakeRequest<string>();
const initialProps: { source: ReactiveActionSource<string> | null } = { source: null };
const { result, rerender } = renderHook(({ source }) => useRequest(source), { initialProps });
expect(result.current.status).toBe('disabled');
expect(req.fn).not.toHaveBeenCalled();

rerender({ source: req.source });
expect(result.current.status).toBe('loading');
expect(req.fn).toHaveBeenCalledTimes(1);
});

it('returns to disabled when the source transitions from a real source to null', async () => {
const req = makeFakeRequest<string>();
const initialProps: { source: ReactiveActionSource<string> | null } = { source: req.source };
const { result, rerender } = renderHook(({ source }) => useRequest(source), { initialProps });
await act(async () => req.resolveLatest('hi'));
expect(result.current.status).toBe('loaded');

rerender({ source: null });
expect(result.current.status).toBe('disabled');
expect(result.current.data).toBeUndefined();
});

it('aborts the in-flight request when the source transitions to null', () => {
const req = makeFakeRequest<string>();
const initialProps: { source: ReactiveActionSource<string> | null } = { source: req.source };
const { rerender } = renderHook(({ source }) => useRequest(source), { initialProps });
const inFlightSignal = req.fn.mock.calls[0]![0];
expect(inFlightSignal.aborted).toBe(false);

rerender({ source: null });
expect(inFlightSignal.aborted).toBe(true);
});

it('aborts the in-flight request when the component unmounts', () => {
const req = makeFakeRequest<string>();
const { unmount } = renderHook(() => useRequest(req.source));
const inFlightSignal = req.fn.mock.calls[0]![0];
expect(inFlightSignal.aborted).toBe(false);

unmount();
expect(inFlightSignal.aborted).toBe(true);
});

it('keeps a stable refresh reference across re-renders', () => {
const req = makeFakeRequest<string>();
const { result, rerender } = renderHook(() => useRequest(req.source));
const { refresh } = result.current;
rerender();
expect(result.current.refresh).toBe(refresh);
});

it('invokes perRequestSignal on every attempt with a fresh signal', () => {
const req = makeFakeRequest<string>();
const signals: AbortSignal[] = [];
const perRequestSignal = jest.fn(() => {
const ctrl = new AbortController();
signals.push(ctrl.signal);
return ctrl.signal;
});
const { result } = renderHook(() => useRequest(req.source, { perRequestSignal }));

expect(perRequestSignal).toHaveBeenCalledTimes(1);

act(() => result.current.refresh());
expect(perRequestSignal).toHaveBeenCalledTimes(2);
expect(signals[1]).not.toBe(signals[0]); // fresh identity per attempt
});

it('aborting the perRequestSignal transitions the current attempt to error; refresh starts a fresh one', async () => {
const req = makeFakeRequest<string>();
let currentCtrl: AbortController | undefined;
const perRequestSignal = () => {
currentCtrl = new AbortController();
return currentCtrl.signal;
};
const { result } = renderHook(() => useRequest(req.source, { perRequestSignal }));

const timeoutReason = new Error('timeout');
await act(async () => currentCtrl!.abort(timeoutReason));
expect(result.current.status).toBe('error');
expect(result.current.error).toBe(timeoutReason);

act(() => result.current.refresh());
expect(currentCtrl!.signal.aborted).toBe(false); // brand-new controller for the new attempt

await act(async () => req.resolveLatest('recovered'));
expect(result.current.status).toBe('loaded');
expect(result.current.data).toBe('recovered');
});
});
19 changes: 19 additions & 0 deletions packages/react/src/__typetests__/useRequest-typetest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* eslint-disable react-hooks/rules-of-hooks */

import { ReactiveActionSource } from '@solana/subscribable';

import { RequestResult, useRequest } from '../useRequest';

const slotSource = null as unknown as ReactiveActionSource<{ slot: bigint }>;

// [DESCRIBE] useRequest
{
// Infers T from the source
useRequest(slotSource) satisfies RequestResult<{ slot: bigint }>;

// The source argument accepts null
useRequest<{ slot: bigint }>(null) satisfies RequestResult<{ slot: bigint }>;

// Options accept a `perRequestSignal` factory
useRequest(slotSource, { perRequestSignal: () => AbortSignal.timeout(5_000) });
}
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './ClientProvider';
export * from './useAction';
export * from './useClient';
export * from './useClientCapability';
export * from './useRequest';
export * from './useSignAndSendTransaction';
export * from './useSignIn';
export * from './useSignMessage';
Expand Down
30 changes: 30 additions & 0 deletions packages/react/src/staticStores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ReactiveActionStore } from '@solana/subscribable';

const DISABLED_ACTION_STATE = Object.freeze({
data: undefined,
error: undefined,
status: 'idle' as const,
});

const noopUnsubscribe = () => {};
const noopSubscribe = () => noopUnsubscribe;
const rejectedAbortError = (): Promise<never> => Promise.reject(new DOMException('Aborted', 'AbortError'));

/**
* A {@link ReactiveActionStore} that never transitions out of `idle` and rejects any attempt to
* dispatch. Returned by `useRequest` (and other action-store hooks) when their factory function
* returns `null`, signalling that the call should be gated off — for example because a required
* input (an address, a query string) is not yet known.
*
* The hook's result bridge maps this store's `idle` state to a `disabled` status so call sites
* can distinguish "not enabled" from "loading" without an extra flag.
*/
export function disabledActionStore<T>(): ReactiveActionStore<[], T> {
return {
dispatch: noopUnsubscribe,
dispatchAsync: rejectedAbortError,
getState: () => DISABLED_ACTION_STATE,
reset: noopUnsubscribe,
subscribe: noopSubscribe,
};
}
7 changes: 2 additions & 5 deletions packages/react/src/useAction.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { createReactiveActionStore } from '@solana/subscribable';
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useEffect, 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;
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';

/**
* Reactive state and controls for an async action managed by {@link useAction}
Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/useIsomorphicLayoutEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useEffect, useLayoutEffect } from 'react';

/**
* `useLayoutEffect` warns when run on the server because layout effects only make sense after a
* DOM is mounted. For our use, plain `useEffect` is functionally equivalent on the server (no
* event handlers can fire mid-render anyway), so we pick at module load time and stay silent
* during SSR.
*
* @internal
*/
export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
Loading
Loading