diff --git a/EXAMPLES.md b/EXAMPLES.md
index 83e5e6aa..9c0fa276 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -22,6 +22,7 @@
- [Passkeys](#passkeys)
- [MyAccount API](#myaccount-api)
- [Session Expiry from Upstream IdP (IPSIE)](#session-expiry-from-upstream-idp-ipsie)
+- [Use Suspense for loading state (React 19+)](#use-suspense-for-loading-state-react-19)
## Use with a Class Component
@@ -1940,3 +1941,47 @@ function CallApi() {
```
Using `withAuthenticationRequired` on protected routes is the simpler alternative — the redirect happens automatically without the null check.
+
+
+## Use Suspense for loading state (React 19+)
+
+On React 19 and later, `useAuth0Suspense` lets a `` boundary handle the
+auth loading state and an Error Boundary handle initialization errors, so your
+component code stays focused on rendering. Unlike `useAuth0`, it does not return
+`isLoading` — the component suspends until auth is ready.
+
+```jsx
+import { Suspense } from 'react';
+import { Auth0Provider, useAuth0Suspense } from '@auth0/auth0-react';
+
+function App() {
+ return (
+
+ Could not sign you in.
;
+}
+
+describe('useAuth0Suspense recovery', () => {
+ it('recovers on boundary retry after auth succeeds via loginWithPopup', async () => {
+ // First call is init (fails). The retry re-checks the session and succeeds.
+ clientMock.checkSession
+ .mockRejectedValueOnce({
+ error: '__test_error__',
+ error_description: '__init_failed__',
+ })
+ .mockResolvedValue(undefined);
+ clientMock.loginWithPopup.mockResolvedValue(undefined);
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+
+ // Lives outside the boundary so it survives the init failure.
+ function Recovery() {
+ const { loginWithPopup } = useAuth0();
+ return ;
+ }
+
+ await act(async () => {
+ render(
+
+
+
+ loading}>
+
+
+
+
+ );
+ });
+
+ // Init failed -> boundary caught it.
+ await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument());
+
+ // Successful login makes the app authenticated, which triggers the retry.
+ await act(async () => {
+ screen.getByText('popup').click();
+ });
+
+ // Boundary retry must now render, not re-throw the stale rejection.
+ await act(async () => {
+ screen.getByText('retry').click();
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText('suspense-ok:true')).toBeInTheDocument()
+ );
+ });
+
+ it('recovers after a silent token succeeds, not just loginWithPopup', async () => {
+ // GET_ACCESS_TOKEN_COMPLETE does not clear `error`, so recovery must key
+ // off `isAuthenticated` rather than the error being cleared.
+ clientMock.checkSession
+ .mockRejectedValueOnce({
+ error: '__test_error__',
+ error_description: '__init_failed__',
+ })
+ .mockResolvedValue(undefined);
+ clientMock.getTokenSilently.mockResolvedValue('__token__' as never);
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+
+ function Recovery() {
+ const { getAccessTokenSilently } = useAuth0();
+ return (
+
+ );
+ }
+
+ await act(async () => {
+ render(
+
+
+
+ loading}>
+
+
+
+
+ );
+ });
+
+ await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument());
+
+ await act(async () => {
+ screen.getByText('token').click();
+ });
+ await act(async () => {
+ screen.getByText('retry').click();
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText('suspense-ok:true')).toBeInTheDocument()
+ );
+ });
+
+ it('keeps rejecting when the retried session check also fails', async () => {
+ // The retry must report what checkSession actually did. If the session is
+ // still bad, the boundary keeps showing an error rather than a fake success.
+ clientMock.checkSession
+ .mockRejectedValueOnce({
+ error: '__test_error__',
+ error_description: '__init_failed__',
+ })
+ .mockRejectedValue({
+ error: '__test_error__',
+ error_description: '__retry_also_failed__',
+ });
+ clientMock.loginWithPopup.mockResolvedValue(undefined);
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+
+ function Recovery() {
+ const { loginWithPopup } = useAuth0();
+ return ;
+ }
+
+ await act(async () => {
+ render(
+
+
+
+ loading}>
+
+
+
+
+ );
+ });
+
+ await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument());
+
+ await act(async () => {
+ screen.getByText('popup').click();
+ });
+ await act(async () => {
+ screen.getByText('retry').click();
+ });
+
+ // The retried check failed too, so the boundary catches again. The hook must
+ // never render content off a fabricated success.
+ await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument());
+ expect(screen.queryByText('suspense-ok:true')).not.toBeInTheDocument();
+ });
+
+ it('does not retry while init is still pending', async () => {
+ // Init hangs until we release it. A successful login in the meantime must
+ // not be mistaken for init having failed-then-recovered.
+ const sessionDefer = defer();
+ clientMock.checkSession.mockReturnValue(sessionDefer.promise as never);
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+ clientMock.loginWithPopup.mockResolvedValue(undefined);
+
+ function Recovery() {
+ const { loginWithPopup } = useAuth0();
+ return ;
+ }
+
+ await act(async () => {
+ render(
+
+
+
+ loading}>
+
+
+
+
+ );
+ });
+
+ await act(async () => {
+ screen.getByText('popup').click();
+ });
+
+ // Still suspended: init has not finished, so the hook must still wait.
+ expect(screen.getByText('loading')).toBeInTheDocument();
+ expect(clientMock.checkSession).toHaveBeenCalledTimes(1);
+
+ // Now let init finish. The consumer must render -- not hang forever on a
+ // deferred that was swapped out from under it.
+ await act(async () => {
+ sessionDefer.resolve();
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText('suspense-ok:true')).toBeInTheDocument()
+ );
+ });
+
+ it('retries at most once', async () => {
+ // A retry that also fails must not re-arm itself, or it would re-run
+ // checkSession for as long as the user stays authenticated.
+ clientMock.checkSession.mockRejectedValue({
+ error: '__test_error__',
+ error_description: '__init_failed__',
+ });
+ clientMock.loginWithPopup.mockResolvedValue(undefined);
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+
+ function Recovery() {
+ const { loginWithPopup } = useAuth0();
+ return ;
+ }
+
+ await act(async () => {
+ render(
+
+
+
+ loading}>
+
+
+
+
+ );
+ });
+
+ await waitFor(() => expect(clientMock.checkSession).toHaveBeenCalled());
+
+ for (let i = 0; i < 3; i++) {
+ await act(async () => {
+ screen.getByText('popup').click();
+ });
+ }
+
+ // One init + exactly one retry.
+ expect(clientMock.checkSession).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not re-run the redirect callback on retry', async () => {
+ // handleRedirectCallback is single-use: replaying it would fail on a
+ // consumed code, and onRedirectCallback must not fire twice.
+ window.history.pushState(
+ {},
+ document.title,
+ '/?code=__test_code__&state=__test_state__'
+ );
+ clientMock.handleRedirectCallback.mockRejectedValueOnce({
+ error: '__redirect_error__',
+ error_description: '__redirect_failed__',
+ });
+ clientMock.checkSession.mockResolvedValue(undefined);
+ clientMock.loginWithPopup.mockResolvedValue(undefined);
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+ const onRedirectCallback = jest.fn();
+
+ function Recovery() {
+ const { loginWithPopup } = useAuth0();
+ return ;
+ }
+
+ await act(async () => {
+ render(
+
+
+
+ loading}>
+
+
+
+
+ );
+ });
+
+ await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument());
+
+ await act(async () => {
+ screen.getByText('popup').click();
+ });
+ await act(async () => {
+ screen.getByText('retry').click();
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText('suspense-ok:true')).toBeInTheDocument()
+ );
+ // The retry took the checkSession branch, not the redirect branch.
+ expect(clientMock.handleRedirectCallback).toHaveBeenCalledTimes(1);
+ expect(onRedirectCallback).not.toHaveBeenCalled();
+ });
+});
+
+describe('useAuth0Suspense provider detection', () => {
+ // Regression guard: the missing-provider check in use-auth0-suspense.tsx
+ // relies on initialContext NOT carrying an _initPromise. If a future change
+ // adds a stub promise there, the guard would silently pass and consumers
+ // would get the throwing stub methods instead of a clear error.
+ it('initialContext carries no _initPromise', () => {
+ expect(
+ (initialContext as { _initPromise?: Promise })._initPromise
+ ).toBeUndefined();
+ });
+});
+
+describe('useAuth0Suspense exports', () => {
+ it('is exported from the package root', async () => {
+ const pkg = await import('../src');
+ expect(typeof pkg.useAuth0Suspense).toBe('function');
+ });
+});
diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx
index 8ff52da1..6c62eb6f 100644
--- a/src/auth0-context.tsx
+++ b/src/auth0-context.tsx
@@ -474,6 +474,16 @@ export interface Auth0ContextInterface
* ```
*/
myAccount: MyAccountApiClient;
+
+ /**
+ * Internal. A promise that resolves when Auth0 initialization completes and
+ * rejects with the initialization error if it fails. Consumed by
+ * `useAuth0Suspense`, which also treats its absence as "no provider" — do not
+ * add a stub value for this field to `initialContext`. Not part of the
+ * supported public API.
+ * @ignore
+ */
+ _initPromise?: Promise;
}
/**
diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx
index 89a93816..a5a110cd 100644
--- a/src/auth0-provider.tsx
+++ b/src/auth0-provider.tsx
@@ -157,6 +157,32 @@ const defaultOnRedirectCallback = (appState?: AppState): void => {
);
};
+/**
+ * @ignore
+ */
+interface InitDeferred {
+ promise: Promise;
+ resolve: () => void;
+ reject: (error: Error) => void;
+}
+
+/**
+ * @ignore
+ */
+const createInitDeferred = (): InitDeferred => {
+ let resolve!: () => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ // Avoid unhandled-rejection warnings when no one is consuming the promise
+ // (i.e. useAuth0Suspense is not used). useAuth0Suspense attaches its own
+ // handler via use().
+ promise.catch(() => undefined);
+ return { promise, resolve, reject };
+};
+
/**
* ```jsx
* (opts: Auth0ProviderOptions providedClient ?? new Auth0Client(toAuth0ClientOptions(clientOpts))
);
const [state, dispatch] = useReducer(reducer, initialAuthState as AuthState);
+ // In state so the init retry below can swap in a fresh, non-rejected promise.
+ // Without that swap the first rejection would re-throw on every later render,
+ // making Error Boundary retries useless.
+ const [initDeferred, setInitDeferred] = useState(createInitDeferred);
+ // Set only once initialization has genuinely rejected. State, not a ref, so
+ // the retry effect re-evaluates when init fails *after* the app has become
+ // authenticated by some other means.
+ const [initFailed, setInitFailed] = useState(false);
+ // Guards against retrying more than once: a retry that also fails would
+ // otherwise re-trigger the effect for as long as the user stays signed in.
+ const initRetried = useRef(false);
const didInitialise = useRef(false);
const handleError = useCallback((error: Error) => {
@@ -201,15 +238,15 @@ const Auth0Provider = (opts: Auth0ProviderOptions {
- if (didInitialise.current) {
- return;
- }
- didInitialise.current = true;
- (async (): Promise => {
+ // Settles `deferred` according to what initialization actually did, so
+ // `_initPromise` never claims success that did not happen. `allowRedirect`
+ // is false on a retry: the redirect callback is single-use, so a retry can
+ // only re-verify the session.
+ const runInit = useCallback(
+ async (deferred: InitDeferred, allowRedirect: boolean): Promise => {
try {
let user: TUser | undefined;
- if (hasAuthParams() && !skipRedirectCallback) {
+ if (allowRedirect && hasAuthParams() && !skipRedirectCallback) {
const { appState = {}, response_type, ...result } = await client.handleRedirectCallback();
user = await client.getUser();
appState.response_type = response_type;
@@ -222,11 +259,44 @@ const Auth0Provider = (opts: Auth0ProviderOptions {
+ if (didInitialise.current) {
+ return;
+ }
+ didInitialise.current = true;
+ // Always the first deferred: `didInitialise` means this only ever runs once,
+ // so a later swap by the retry effect re-runs this effect but returns above.
+ void runInit(initDeferred, true);
+ }, [runInit, initDeferred]);
+
+ // A rejected promise stays rejected forever, so React.use() would re-throw on
+ // every later render and an Error Boundary retry could never recover. Once
+ // init has failed but the app has since become authenticated by some other
+ // means (loginWithPopup, a silent token, a passkey login), re-run the session
+ // check against a fresh deferred. The promise then reflects a real
+ // checkSession() outcome rather than an assumption that things are fine --
+ // if the session is still bad it rejects again and the boundary keeps showing
+ // the error.
+ useEffect(() => {
+ if (!initFailed || initRetried.current || !state.isAuthenticated) {
+ return;
+ }
+ initRetried.current = true;
+ const retry = createInitDeferred();
+ setInitDeferred(retry);
+ void runInit(retry, false);
+ }, [initFailed, state.isAuthenticated, runInit]);
const loginWithRedirect = useCallback(
(opts?: RedirectLoginOptions): Promise => {
@@ -485,6 +555,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions{children};
diff --git a/src/index.tsx b/src/index.tsx
index 06fc6834..5c167feb 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -7,6 +7,10 @@ export {
ConnectedAccount
} from './auth0-provider';
export { default as useAuth0 } from './use-auth0';
+export {
+ default as useAuth0Suspense,
+ Auth0SuspenseContextInterface,
+} from './use-auth0-suspense';
export { default as withAuth0, WithAuth0Props } from './with-auth0';
export {
default as withAuthenticationRequired,
diff --git a/src/use-auth0-suspense.tsx b/src/use-auth0-suspense.tsx
new file mode 100644
index 00000000..c727f915
--- /dev/null
+++ b/src/use-auth0-suspense.tsx
@@ -0,0 +1,71 @@
+// Namespace import: `use` only exists as a named export from React 19, so
+// `import { use }` fails at link time for React 16-18 consumers even if they
+// never call this hook. Property access stays late-bound.
+import * as React from 'react';
+import { User } from '@auth0/auth0-spa-js';
+import Auth0Context, { Auth0ContextInterface } from './auth0-context';
+
+/**
+ * The value returned by `useAuth0Suspense`: the full `useAuth0` interface minus
+ * `isLoading` and the internal `_initPromise`. `error` is
+ * retained for post-init failures such as `loginWithPopup`.
+ */
+export type Auth0SuspenseContextInterface = Omit<
+ Auth0ContextInterface,
+ 'isLoading' | '_initPromise'
+>;
+
+/**
+ * ```jsx
+ * }>
+ *
+ *
+ *
+ * function Profile() {
+ * const { user, isAuthenticated } = useAuth0Suspense();
+ * return isAuthenticated ?
Hello {user.name}
:
Please log in
;
+ * }
+ * ```
+ *
+ * Suspense-enabled variant of `useAuth0`. Suspends the component until Auth0
+ * initialization completes (letting the nearest `` fallback render),
+ * and throws initialization errors so the nearest Error Boundary can handle
+ * them. Requires React 19 or later.
+ *
+ * If initialization fails and the app later becomes authenticated by other
+ * means, the session is re-checked once; retrying the Error Boundary then
+ * renders if that check succeeded, or throws again if it did not.
+ *
+ * TUser is an optional type param to provide a type to the `user` field.
+ */
+const useAuth0Suspense = (
+ context = Auth0Context
+): Auth0SuspenseContextInterface => {
+ if (typeof React.use !== 'function') {
+ throw new Error(
+ 'useAuth0Suspense requires React 19 or later (React.use is unavailable).'
+ );
+ }
+
+ const ctx = React.useContext(context) as Auth0ContextInterface;
+
+ if (!ctx._initPromise) {
+ throw new Error(
+ 'useAuth0Suspense must be used within an .'
+ );
+ }
+
+ // Suspends until the init promise resolves; re-throws if it rejected.
+ React.use(ctx._initPromise);
+
+ // Memoized so the returned object is referentially stable across renders,
+ // matching useAuth0, which hands back the provider's memoized context.
+ // Without this, `useEffect(..., [auth])` in a consumer re-runs every render.
+ return React.useMemo(() => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { isLoading, _initPromise, ...rest } = ctx;
+ return rest;
+ }, [ctx]);
+};
+
+export default useAuth0Suspense;