From 7eeb79af4e816106179a66b7878a03df33740248 Mon Sep 17 00:00:00 2001
From: Gyanesh Gouraw
Date: Wed, 29 Jul 2026 14:00:11 +0530
Subject: [PATCH] feat: add useAuth0Suspense hook for handling auth loading
state with React 19+
---
EXAMPLES.md | 39 ++++++++
__tests__/auth-provider.test.tsx | 36 +++++++
__tests__/use-auth0-suspense.test.tsx | 133 ++++++++++++++++++++++++++
src/auth0-context.tsx | 8 ++
src/auth0-provider.tsx | 21 +++-
src/index.tsx | 4 +
src/use-auth0-suspense.tsx | 59 ++++++++++++
7 files changed, 298 insertions(+), 2 deletions(-)
create mode 100644 __tests__/use-auth0-suspense.test.tsx
create mode 100644 src/use-auth0-suspense.tsx
diff --git a/EXAMPLES.md b/EXAMPLES.md
index e9255a97..ef109ce5 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -21,6 +21,45 @@
- [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 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) or `error` (it is thrown
+to the nearest Error Boundary).
+
+```jsx
+import { Suspense } from 'react';
+import { Auth0Provider, useAuth0Suspense } from '@auth0/auth0-react';
+
+function App() {
+ return (
+
+ Could not sign you in.
}>
+ Loading...}>
+
+
+
+
+ );
+}
+
+function UserGreeting() {
+ const { user, isAuthenticated } = useAuth0Suspense();
+ return isAuthenticated ? Hello, {user?.name}!
: Please log in
;
+}
+```
+
+`useAuth0Suspense` requires React 19; calling it on an earlier version throws a
+clear error. All the auth methods available on `useAuth0` (`loginWithRedirect`,
+`logout`, `getAccessTokenSilently`, etc.) are also available here.
## Use with a Class Component
diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx
index 9956f288..3afef34a 100644
--- a/__tests__/auth-provider.test.tsx
+++ b/__tests__/auth-provider.test.tsx
@@ -1635,4 +1635,40 @@ describe('Auth0Provider', () => {
expect(result.current.error).toBeUndefined();
});
});
+
+ describe('Auth0Provider _initPromise', () => {
+ it('exposes an _initPromise that resolves after successful init', async () => {
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useContext(Auth0Context), { wrapper });
+
+ expect(result.current._initPromise).toBeInstanceOf(Promise);
+ // resolves (not rejects) once init completes
+ await expect(result.current._initPromise).resolves.toBeUndefined();
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ });
+
+ it('rejects _initPromise when init fails', async () => {
+ clientMock.checkSession.mockRejectedValueOnce({
+ error: '__test_error__',
+ error_description: '__test_error_description__',
+ });
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useContext(Auth0Context), { wrapper });
+
+ await expect(result.current._initPromise).rejects.toThrowError(
+ '__test_error_description__'
+ );
+ });
+
+ it('keeps a stable _initPromise reference across re-renders', async () => {
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+ const wrapper = createWrapper();
+ const { result, rerender } = renderHook(() => useContext(Auth0Context), { wrapper });
+ const first = result.current._initPromise;
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ rerender();
+ expect(result.current._initPromise).toBe(first);
+ });
+ });
});
diff --git a/__tests__/use-auth0-suspense.test.tsx b/__tests__/use-auth0-suspense.test.tsx
new file mode 100644
index 00000000..2f073f20
--- /dev/null
+++ b/__tests__/use-auth0-suspense.test.tsx
@@ -0,0 +1,133 @@
+import { Auth0Client } from '@auth0/auth0-spa-js';
+import '@testing-library/jest-dom';
+import {
+ act,
+ render,
+ renderHook,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import React, { Component, ReactNode, Suspense } from 'react';
+import { Auth0Provider } from '../src';
+import useAuth0Suspense from '../src/use-auth0-suspense';
+import { defer } from './helpers';
+
+const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' }));
+
+class ErrorBoundary extends Component<
+ { children: ReactNode },
+ { error: Error | null }
+> {
+ state = { error: null as Error | null };
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+ render() {
+ return this.state.error ? (
+ boundary: {this.state.error.message}
+ ) : (
+ this.props.children
+ );
+ }
+}
+
+function Greeting() {
+ const { user, isAuthenticated } = useAuth0Suspense();
+ return {isAuthenticated ? `Hello ${user?.name}` : 'Please log in'}
;
+}
+
+const renderWithProvider = async (child: ReactNode) =>
+ act(async () => {
+ render(
+
+
+ loading}>{child}
+
+
+ );
+ });
+
+describe('useAuth0Suspense', () => {
+ afterEach(() => {
+ window.history.pushState({}, document.title, '/');
+ });
+
+ it('shows the Suspense fallback while init is pending, then the content', async () => {
+ const userDefer = defer<{ name: string }>();
+ clientMock.checkSession.mockResolvedValue(undefined);
+ clientMock.getUser.mockReturnValue(userDefer.promise as never);
+
+ await renderWithProvider();
+
+ // Still initializing -> fallback
+ expect(screen.getByText('loading')).toBeInTheDocument();
+
+ userDefer.resolve({ name: 'Bob' });
+
+ await waitFor(() =>
+ expect(screen.getByText('Hello Bob')).toBeInTheDocument()
+ );
+ });
+
+ it('throws init errors to the nearest Error Boundary', async () => {
+ clientMock.checkSession.mockRejectedValueOnce({
+ error: '__test_error__',
+ error_description: '__test_error_description__',
+ });
+
+ await renderWithProvider();
+
+ await waitFor(() =>
+ expect(
+ screen.getByText(/boundary: .*__test_error_description__/)
+ ).toBeInTheDocument()
+ );
+ });
+
+ it('throws redirect-callback init errors to the nearest Error Boundary', async () => {
+ // Presence of code/state in the URL makes hasAuthParams() true, so init
+ // takes the handleRedirectCallback branch instead of checkSession.
+ window.history.pushState(
+ {},
+ document.title,
+ '/?code=__test_code__&state=__test_state__'
+ );
+ clientMock.handleRedirectCallback.mockRejectedValueOnce({
+ error: '__redirect_error__',
+ error_description: '__redirect_error_description__',
+ });
+
+ await renderWithProvider();
+
+ await waitFor(() =>
+ expect(
+ screen.getByText(/boundary: .*__redirect_error_description__/)
+ ).toBeInTheDocument()
+ );
+ });
+
+ it('throws a clear error when used outside an Auth0Provider', () => {
+ expect(() => renderHook(() => useAuth0Suspense())).toThrowError(
+ /must be used within/
+ );
+ });
+
+ it('throws a clear error when React.use is unavailable', async () => {
+ jest.resetModules();
+ jest.doMock('react', () => {
+ const actual = jest.requireActual('react');
+ return { ...actual, use: undefined };
+ });
+ const { default: hook } = await import('../src/use-auth0-suspense');
+ expect(() => hook()).toThrowError(/requires React 19/);
+ jest.dontMock('react');
+ jest.resetModules();
+ });
+});
+
+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..1b96c018 100644
--- a/src/auth0-context.tsx
+++ b/src/auth0-context.tsx
@@ -474,6 +474,14 @@ 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`. Not part of the supported public API.
+ * @ignore
+ */
+ _initPromise?: Promise;
}
/**
diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx
index e9dedd76..7121a476 100644
--- a/src/auth0-provider.tsx
+++ b/src/auth0-provider.tsx
@@ -189,6 +189,18 @@ const Auth0Provider = (opts: Auth0ProviderOptions providedClient ?? new Auth0Client(toAuth0ClientOptions(clientOpts))
);
const [state, dispatch] = useReducer(reducer, initialAuthState as AuthState);
+ const [initDeferred] = useState(() => {
+ 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 };
+ });
const didInitialise = useRef(false);
const handleError = useCallback((error: Error) => {
@@ -217,11 +229,14 @@ const Auth0Provider = (opts: Auth0ProviderOptions => {
@@ -480,6 +495,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..eacbb52a
--- /dev/null
+++ b/src/use-auth0-suspense.tsx
@@ -0,0 +1,59 @@
+import { use, useContext } 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` (always resolved by the time the hook returns) and `error`
+ * (thrown to the nearest Error Boundary instead of returned).
+ */
+export type Auth0SuspenseContextInterface = Omit<
+ Auth0ContextInterface,
+ 'isLoading' | 'error'
+>;
+
+/**
+ * ```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.
+ *
+ * TUser is an optional type param to provide a type to the `user` field.
+ */
+const useAuth0Suspense = (
+ context = Auth0Context
+): Auth0SuspenseContextInterface => {
+ if (typeof use !== 'function') {
+ throw new Error(
+ 'useAuth0Suspense requires React 19 or later (React.use is unavailable).'
+ );
+ }
+
+ const ctx = 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.
+ use(ctx._initPromise);
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { isLoading, error, ...rest } = ctx;
+ return rest;
+};
+
+export default useAuth0Suspense;