Repository: formspree/formspree-js
Pull request #70: Support React 19
Author: login: bhongy
Created: 2025-03-02T08:25:32Z | Merged: 2025-03-13T04:53:58Z

This PR adds support for React 19 in `@formspree/react`, requiring a major version bump.

It also includes several major changes:

- Upgrade `@stripe/stripe-js` to 5.7.0 and `@stripe/react-stripe-js` to 3.1.1.
- `@formspree/react`: `useSubmit` now returns a `Promise<SubmissionResult>` instead of using `onError` and `onSuccess` options, making it easier to work with `useActionState` and similar/related hooks.
- Add a React 19 example to `cra-demo`.


Diff:
diff --git a/.changeset/wild-cars-raise.md b/.changeset/wild-cars-raise.md
new file mode 100644
index 0000000..27ac23d
--- /dev/null
+++ b/.changeset/wild-cars-raise.md
@@ -0,0 +1,6 @@
+---
+'@formspree/react': major
+'@formspree/core': major
+---
+
+Support React 19 and stripe-js 5
diff --git a/examples/cra-demo/package.json b/examples/cra-demo/package.json
index 8d736b5..4744729 100644
--- a/examples/cra-demo/package.json
+++ b/examples/cra-demo/package.json
@@ -23,17 +23,17 @@
   },
   "dependencies": {
     "@formspree/react": "*",
-    "react": "^18.2.0",
+    "react": "^19.0.0",
     "react-copy-to-clipboard": "^5.1.0",
-    "react-dom": "^18.2.0",
+    "react-dom": "^19.0.0",
     "react-google-recaptcha-v3": "^1.10.1",
-    "react-hook-form": "7.49.2",
+    "react-hook-form": "^7.54.2",
     "react-scripts": "5.0.1",
     "web-vitals": "^3.4.0"
   },
   "devDependencies": {
-    "@types/react": "^18.0.15",
-    "@types/react-copy-to-clipboard": "^5.0.3",
-    "@types/react-dom": "^18.0.6"
+    "@types/react": "^19.0.0",
+    "@types/react-copy-to-clipboard": "^5.0.7",
+    "@types/react-dom": "^19.0.0"
   }
 }
diff --git a/examples/cra-demo/src/App.tsx b/examples/cra-demo/src/App.tsx
index 1ad5c39..0b6d565 100644
--- a/examples/cra-demo/src/App.tsx
+++ b/examples/cra-demo/src/App.tsx
@@ -1,12 +1,22 @@
-import { useState } from 'react';
 import { FormspreeProvider } from '@formspree/react';
+import { useState } from 'react';
+
+import { React19 } from './React19';
+import { WithReactHookForm } from './WithReactHookForm';
 import PaymentForm from './PaymentForm';
-import SimpleForm from './SimpleForm';
 import RecaptchaForm from './RecaptchaForm';
-import { WithReactHookForm } from './WithReactHookForm';
+import SimpleForm from './SimpleForm';
+
+enum Tab {
+  React19 = 'react-19',
+  ReactHookForm = 'react-hook-form',
+  Recaptcha = 'recaptcha',
+  Simple = 'simple',
+  Stripe = 'stripe',
+}
 
 const App = () => {
-  const [tab, setTab] = useState('simple');
+  const [tab, setTab] = useState(Tab.Simple);
 
   return (
     <>
@@ -14,44 +24,53 @@ const App = () => {
         <div className="tabs">
           <button
             type="button"
-            className={`tab ${tab === 'simple' && 'active'}`}
-            onClick={() => setTab('simple')}
+            className={`tab ${tab === Tab.Simple && 'active'}`}
+            onClick={() => setTab(Tab.Simple)}
           >
             Simple form
           </button>
           <button
             type="button"
-            className={`tab ${tab === 'recaptcha' && 'active'}`}
-            onClick={() => setTab('recaptcha')}
+            className={`tab ${tab === Tab.Recaptcha && 'active'}`}
+            onClick={() => setTab(Tab.Recaptcha)}
           >
             ReCaptcha form
           </button>
           <button
             type="button"
-            className={`tab ${tab === 'stripe' && 'active'}`}
+            className={`tab ${tab === Tab.Stripe && 'active'}`}
             disabled={!process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY}
-            onClick={() => setTab('stripe')}
+            onClick={() => setTab(Tab.Stripe)}
           >
             Stripe form
           </button>
           <button
             type="button"
-            className={`tab ${tab === 'react-hook-form' && 'active'}`}
-            onClick={() => setTab('react-hook-form')}
+            className={`tab ${tab === Tab.ReactHookForm && 'active'}`}
+            onClick={() => setTab(Tab.ReactHookForm)}
           >
             With react-hook-form
           </button>
+          <button
+            type="button"
+            className={`tab ${tab === Tab.React19 && 'active'}`}
+            onClick={() => setTab(Tab.React19)}
+          >
+            React 19
+          </button>
         </div>
-        {tab === 'stripe' ? (
+        {tab === Tab.Stripe ? (
           <FormspreeProvider
             stripePK={process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY}
           >
             <PaymentForm />
           </FormspreeProvider>
-        ) : tab === 'recaptcha' ? (
+        ) : tab === Tab.Recaptcha ? (
           <RecaptchaForm />
-        ) : tab === 'react-hook-form' ? (
+        ) : tab === Tab.ReactHookForm ? (
           <WithReactHookForm />
+        ) : tab === Tab.React19 ? (
+          <React19 />
         ) : (
           <SimpleForm />
         )}
diff --git a/examples/cra-demo/src/React19.tsx b/examples/cra-demo/src/React19.tsx
new file mode 100644
index 0000000..f690cb4
--- /dev/null
+++ b/examples/cra-demo/src/React19.tsx
@@ -0,0 +1,45 @@
+import { isSubmissionError, type SubmissionResult } from '@formspree/core';
+import { useSubmit, ValidationError } from '@formspree/react';
+import { useActionState } from 'react';
+
+export function React19() {
+  const submit = useSubmit(process.env.REACT_APP_SIMPLE_FORM_ID as string);
+
+  const [state, action, isPending] = useActionState<
+    SubmissionResult | null,
+    FormData
+  >((_, inputs) => submit(inputs), null);
+
+  if (state && !isSubmissionError(state)) {
+    return <h2>Your message has been sent successfully!</h2>;
+  }
+
+  return (
+    <form action={action}>
+      <div className="block">
+        <label htmlFor="email">Email</label>
+        <input id="email" type="email" name="email" />
+        <ValidationError
+          field="email"
+          prefix="Email"
+          className="error"
+          errors={state}
+        />
+      </div>
+      <div className="block">
+        <label htmlFor="name">Name</label>
+        <input id="name" name="name" />
+      </div>
+      <div className="block">
+        <label htmlFor="message">Message</label>
+        <textarea id="message" name="message" rows={10} />
+      </div>
+      <div className="block">
+        <ValidationError className="error" errors={state} />
+      </div>
+      <button type="submit" disabled={isPending}>
+        {isPending ? 'Submitting...' : 'Submit'}
+      </button>
+    </form>
+  );
+}
diff --git a/examples/cra-demo/src/WithReactHookForm.tsx b/examples/cra-demo/src/WithReactHookForm.tsx
index de48f31..de0d167 100644
--- a/examples/cra-demo/src/WithReactHookForm.tsx
+++ b/examples/cra-demo/src/WithReactHookForm.tsx
@@ -1,3 +1,4 @@
+import { isSubmissionError } from '@formspree/core';
 import { useSubmit } from '@formspree/react';
 import { useForm } from 'react-hook-form';
 
@@ -16,33 +17,36 @@ export function WithReactHookForm() {
   } = useForm<Inputs>();
 
   const submit = useSubmit<Inputs>(
-    process.env.REACT_APP_REACT_HOOK_FORM_ID as string,
-    {
-      onError(errs) {
-        const formErrs = errs.getFormErrors();
-        for (const { code, message } of formErrs) {
-          setError(`root.${code}`, {
-            type: code,
-            message,
-          });
-        }
+    process.env.REACT_APP_REACT_HOOK_FORM_ID as string
+  );
+
+  async function onSubmit(inputs: Inputs): Promise<void> {
+    const result = await submit(inputs);
 
-        const fieldErrs = errs.getAllFieldErrors();
-        for (const [field, errs] of fieldErrs) {
-          setError(field, {
-            message: errs.map((e) => e.message).join(', '),
-          });
-        }
-      },
+    if (isSubmissionError(result)) {
+      const formErrs = result.getFormErrors();
+      for (const { code, message } of formErrs) {
+        setError(`root.${code}`, {
+          type: code,
+          message,
+        });
+      }
+
+      const fieldErrs = result.getAllFieldErrors();
+      for (const [field, errs] of fieldErrs) {
+        setError(field, {
+          message: errs.map((e) => e.message).join(', '),
+        });
+      }
     }
-  );
+  }
 
   return (
     <div>
       {isSubmitSuccessful ? (
         <h2>Your message has been sent successfully!</h2>
       ) : (
-        <form onSubmit={handleSubmit(submit)}>
+        <form onSubmit={handleSubmit(onSubmit)}>
           <div className="block">
             <label htmlFor="email">Email</label>
             <input {...register('email')} id="email" type="email" />
diff --git a/examples/cra-demo/src/index.css b/examples/cra-demo/src/index.css
index 64e6a78..6fc08c4 100644
--- a/examples/cra-demo/src/index.css
+++ b/examples/cra-demo/src/index.css
@@ -16,11 +16,12 @@ body {
 .container {
   max-width: 960px;
   margin: 0 auto;
-  padding: 4rem 0;
+  padding: 4rem 2rem;
 }
 
 .tabs {
   display: flex;
+  flex-wrap: wrap;
   justify-content: flex-start;
   align-items: center;
   margin-bottom: 4rem;
@@ -194,4 +195,4 @@ input:focus,
   color: #fff;
   padding: 0.3rem 0.5rem;
   border-radius: 5px;
-}
\ No newline at end of file
+}
diff --git a/packages/formspree-core/package.json b/packages/formspree-core/package.json
index 96716ee..49ee7c3 100644
--- a/packages/formspree-core/package.json
+++ b/packages/formspree-core/package.json
@@ -33,7 +33,7 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@stripe/stripe-js": "^1.35.0"
+    "@stripe/stripe-js": "^5.7.0"
   },
   "publishConfig": {
     "access": "public",
diff --git a/packages/formspree-core/src/submission.ts b/packages/formspree-core/src/submission.ts
index 68238e6..7bdda2c 100644
--- a/packages/formspree-core/src/submission.ts
+++ b/packages/formspree-core/src/submission.ts
@@ -12,7 +12,7 @@ export type SubmissionOptions = {
   endpoint?: string;
 };
 
-export type SubmissionResult<T extends FieldValues> =
+export type SubmissionResult<T extends FieldValues = FieldValues> =
   | SubmissionSuccess
   | SubmissionError<T>;
 
@@ -72,7 +72,7 @@ export function isSubmissionError<T extends FieldValues>(
   return result.kind === 'error';
 }
 
-export class SubmissionError<T extends FieldValues> {
+export class SubmissionError<T extends FieldValues = FieldValues> {
   readonly kind = 'error';
 
   private readonly formErrors: FormError[] = [];
diff --git a/packages/formspree-react/package.json b/packages/formspree-react/package.json
index 378deed..e438fb1 100644
--- a/packages/formspree-react/package.json
+++ b/packages/formspree-react/package.json
@@ -33,24 +33,24 @@
   },
   "dependencies": {
     "@formspree/core": "^3.0.4",
-    "@stripe/react-stripe-js": "^1.7.1",
-    "@stripe/stripe-js": "^1.35.0"
+    "@stripe/react-stripe-js": "^3.1.1",
+    "@stripe/stripe-js": "^5.7.0"
   },
   "devDependencies": {
     "@babel/preset-react": "^7.22.5",
     "@swc/core": "^1.3.61",
     "@testing-library/dom": "^9.3.0",
-    "@testing-library/react": "^14.0.0",
+    "@testing-library/react": "^16.2.0",
     "@testing-library/user-event": "^14.4.3",
-    "@types/react": "^18.0.15",
-    "@types/react-dom": "^18.0.6",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
     "eslint-plugin-react-hooks": "^4.3.0",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0"
   },
   "peerDependencies": {
-    "react": "^16.8 || ^17.0 || ^18.0",
-    "react-dom": "^16.8 || ^17.0 || ^18.0"
+    "react": "^16.8 || ^17.0 || ^18.0 || ^19.0",
+    "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0"
   },
   "publishConfig": {
     "access": "public",
diff --git a/packages/formspree-react/src/context.tsx b/packages/formspree-react/src/context.tsx
index 8dd67ce..65b6798 100644
--- a/packages/formspree-react/src/context.tsx
+++ b/packages/formspree-react/src/context.tsx
@@ -1,6 +1,6 @@
 import { createClient, getDefaultClient, type Client } from '@formspree/core';
 import { Elements } from '@stripe/react-stripe-js';
-import { loadStripe } from '@stripe/stripe-js/pure.js';
+import { loadStripe } from '@stripe/stripe-js/pure/index.js';
 import React, {
   useContext,
   useEffect,
diff --git a/packages/formspree-react/src/types.ts b/packages/formspree-react/src/types.ts
index 37547b0..17aed78 100644
--- a/packages/formspree-react/src/types.ts
+++ b/packages/formspree-react/src/types.ts
@@ -1,3 +1,6 @@
+import type { FieldValues, SubmissionData } from '@formspree/core';
+import type { FormEvent as ReactFormEvent } from 'react';
+
 /**
  * ExtraData values can be strings or functions that return a string, or a
  * promise that resolves to a string. Errors should be handled internally.
@@ -14,3 +17,9 @@ export type ExtraDataValue =
 export type ExtraData = {
   [key: string]: ExtraDataValue;
 };
+
+export type FormEvent = ReactFormEvent<HTMLFormElement>;
+
+export type SubmitHandler<T extends FieldValues, R> = (
+  submission: FormEvent | SubmissionData<T>
+) => Promise<R>;
diff --git a/packages/formspree-react/src/useForm.ts b/packages/formspree-react/src/useForm.ts
index fe46a91..1aeff49 100644
--- a/packages/formspree-react/src/useForm.ts
+++ b/packages/formspree-react/src/useForm.ts
@@ -1,12 +1,13 @@
 import {
+  isSubmissionError,
   type Client,
   type FieldValues,
   type SubmissionError,
   type SubmissionSuccess,
 } from '@formspree/core';
 import { useState } from 'react';
-import type { ExtraData } from './types';
-import { useSubmit, type SubmitHandler } from './useSubmit';
+import type { ExtraData, SubmitHandler } from './types';
+import { useSubmit } from './useSubmit';
 
 type ResetFunction = () => void;
 
@@ -17,7 +18,7 @@ export type TUseForm<T extends FieldValues> = [
     submitting: boolean;
     succeeded: boolean;
   },
-  SubmitHandler<T>,
+  SubmitHandler<T, void>,
   ResetFunction
 ];
 
@@ -44,17 +45,6 @@ export function useForm<T extends FieldValues>(
   const submit = useSubmit<T>(formKey, {
     client: args.client,
     extraData: args.data,
-    onError(error) {
-      setErrors(error);
-      setSubmitting(false);
-      setSucceeded(false);
-    },
-    onSuccess(result) {
-      setErrors(null);
-      setResult(result);
-      setSubmitting(false);
-      setSucceeded(true);
-    },
     origin: args.endpoint,
   });
 
@@ -63,7 +53,16 @@ export function useForm<T extends FieldValues>(
 
     async function handleSubmit(submissionData) {
       setSubmitting(true);
-      await submit(submissionData);
+      const result = await submit(submissionData);
+      setSubmitting(false);
+      if (isSubmissionError(result)) {
+        setErrors(result);
+        setSucceeded(false);
+      } else {
+        setErrors(null);
+        setResult(result);
+        setSucceeded(true);
+      }
     },
 
     function reset() {
diff --git a/packages/formspree-react/src/useSubmit.ts b/packages/formspree-react/src/useSubmit.ts
index 5cc9829..ab049a4 100644
--- a/packages/formspree-react/src/useSubmit.ts
+++ b/packages/formspree-react/src/useSubmit.ts
@@ -1,48 +1,32 @@
 import {
   appendExtraData,
-  isSubmissionError,
   type Client,
   type FieldValues,
   type SubmissionData,
-  type SubmissionError,
-  type SubmissionSuccess,
+  type SubmissionResult,
 } from '@formspree/core';
 import { version } from '../package.json';
 import { useFormspree } from './context';
-import type { ExtraData } from './types';
+import type { ExtraData, FormEvent, SubmitHandler } from './types';
 import { CardElement } from '@stripe/react-stripe-js';
 import { useStripeContext } from './stripe';
 
 const clientName = `@formspree/react@${version}`;
 
-type Options<T extends FieldValues> = {
+type Options = {
   client?: Client;
   extraData?: ExtraData;
-  onError?: (error: SubmissionError<T>) => void;
-  onSuccess?: (data: SubmissionSuccess) => void;
   // origin overrides the submission origin (default: "https://formspree.io")
   origin?: string;
 };
 
-type FormEvent = React.FormEvent<HTMLFormElement>;
-
-export type SubmitHandler<T extends FieldValues> = (
-  submission: FormEvent | SubmissionData<T>
-) => Promise<void>;
-
 export function useSubmit<T extends FieldValues>(
   formKey: string,
-  options: Options<T> = {}
-): SubmitHandler<T> {
+  options: Options = {}
+): SubmitHandler<T, SubmissionResult<T>> {
   const formspree = useFormspree();
+  const { client = formspree.client, extraData, origin } = options;
   const { elements } = useStripeContext();
-  const {
-    client = formspree.client,
-    extraData,
-    onError,
-    onSuccess,
-    origin,
-  } = options;
 
   const { stripe } = client;
 
@@ -75,17 +59,11 @@ export function useSubmit<T extends FieldValues>(
             })
         : undefined;
 
-    const result = await client.submitForm(formKey, data, {
+    return client.submitForm(formKey, data, {
       endpoint: origin,
       clientName,
       createPaymentMethod,
     });
-
-    if (isSubmissionError(result)) {
-      onError?.(result);
-    } else {
-      onSuccess?.(result);
-    }
   };
 }
 
diff --git a/packages/formspree-react/test/context.test.tsx b/packages/formspree-react/test/context.test.tsx
index feca7a2..e64ee35 100644
--- a/packages/formspree-react/test/context.test.tsx
+++ b/packages/formspree-react/test/context.test.tsx
@@ -1,12 +1,12 @@
 import { useStripe } from '@stripe/react-stripe-js';
 import type { Stripe } from '@stripe/stripe-js';
-import { loadStripe } from '@stripe/stripe-js/pure.js';
+import { loadStripe } from '@stripe/stripe-js/pure/index.js';
 import { render, renderHook, waitFor } from '@testing-library/react';
 import React from 'react';
 import { CardElement, FormspreeProvider, useFormspree } from '../src';
 import { createMockStripe } from './mockStripe';
 
-jest.mock('@stripe/stripe-js/pure.js');
+jest.mock('@stripe/stripe-js/pure/index.js');
 
 describe('FormspreeProvider', () => {
   describe('default', () => {
diff --git a/packages/formspree-react/test/useSubmit.test.tsx b/packages/formspree-react/test/useSubmit.test.tsx
index cbd01f2..5760ff8 100644
--- a/packages/formspree-react/test/useSubmit.test.tsx
+++ b/packages/formspree-react/test/useSubmit.test.tsx
@@ -1,13 +1,13 @@
-import { isSubmissionError, type SubmissionError } from '@formspree/core';
+import { SubmissionError, type SubmissionResult } from '@formspree/core';
 import type {
   PaymentIntentResult,
   PaymentMethodResult,
   Stripe,
 } from '@stripe/stripe-js';
-import { loadStripe } from '@stripe/stripe-js/pure.js';
+import { loadStripe } from '@stripe/stripe-js/pure/index.js';
 import { render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import React from 'react';
+import React, { type FormEvent } from 'react';
 import {
   FormspreeProvider,
   useFormspree,
@@ -16,7 +16,7 @@ import {
 } from '../src';
 import { createMockStripe } from './mockStripe';
 
-jest.mock('@stripe/stripe-js/pure.js');
+jest.mock('@stripe/stripe-js/pure/index.js');
 
 describe('useSubmit', () => {
   const mockedFetch = jest.spyOn(window, 'fetch');
@@ -208,27 +208,30 @@ describe('useSubmit', () => {
   });
 
   describe('when submission fails', () => {
-    const onError = jest.fn();
-    const onSuccess = jest.fn();
+    type FormData = {
+      email: string;
+    };
+
+    let result: SubmissionResult<FormData>;
 
     function TestForm() {
-      const handleSubmit = useSubmit<{ email: string }>('test-formspree-key', {
-        onError,
-        onSuccess,
-      });
+      const submit = useSubmit<FormData>('test-formspree-key');
+
+      async function handleSubmit(
+        event: FormEvent<HTMLFormElement>
+      ): Promise<void> {
+        event.preventDefault();
+        result = await submit({ email: 'test-email' });
+      }
+
       return (
-        <form
-          onSubmit={(event) => {
-            event.preventDefault();
-            handleSubmit({ email: 'test-email' });
-          }}
-        >
+        <form onSubmit={handleSubmit}>
           <button>Submit</button>
         </form>
       );
     }
 
-    it('calls onError option with the error result', async () => {
+    it('returns an error result', async () => {
       mockedFetch.mockResolvedValue(
         new Response(
           JSON.stringify({
@@ -255,13 +258,8 @@ describe('useSubmit', () => {
 
       await userEvent.click(screen.getByRole('button'));
 
-      expect(onError).toHaveBeenCalledTimes(1);
-
-      const errorResult = onError.mock.calls[0][0] as SubmissionError<{
-        email: string;
-      }>;
-
-      expect(isSubmissionError(errorResult)).toBe(true);
+      expect(result).toBeInstanceOf(SubmissionError);
+      const errorResult = result as SubmissionError<FormData>;
       expect(errorResult.getFormErrors()).toEqual([
         {
           code: 'EMPTY',
@@ -285,34 +283,34 @@ describe('useSubmit', () => {
           ],
         ],
       ]);
-
-      expect(onSuccess).not.toHaveBeenCalled();
     });
   });
 
   describe('when submission succeeds', () => {
-    const onError = jest.fn();
-    const onSuccess = jest.fn();
+    type FormData = {
+      email: string;
+    };
+
+    let result: SubmissionResult<FormData>;
 
     function TestForm() {
-      const handleSubmit = useSubmit<{ email: string }>('test-formspree-key', {
-        onError,
-        onSuccess,
-      });
+      const submit = useSubmit<FormData>('test-formspree-key');
+
+      async function handleSubmit(
+        event: FormEvent<HTMLFormElement>
+      ): Promise<void> {
+        event.preventDefault();
+        result = await submit({ email: 'test-email' });
+      }
 
       return (
-        <form
-          onSubmit={(event) => {
-            event.preventDefault();
-            handleSubmit({ email: 'test-email' });
-          }}
-        >
+        <form onSubmit={handleSubmit}>
           <button>Submit</button>
         </form>
       );
     }
 
-    it('calls onSuccess option with the success result', async () => {
+    it('returns a success result', async () => {
       mockedFetch.mockResolvedValue(
         new Response(JSON.stringify({ next: 'test-redirect-url' }))
       );
@@ -325,11 +323,7 @@ describe('useSubmit', () => {
 
       await userEvent.click(screen.getByRole('button'));
 
-      expect(onError).not.toHaveBeenCalled();
-
-      expect(onSuccess).toHaveBeenCalledTimes(1);
-      const successResult = onSuccess.mock.calls[0][0];
-      expect(successResult).toEqual({
+      expect(result).toEqual({
         kind: 'success',
         next: 'test-redirect-url',
       });
@@ -337,7 +331,7 @@ describe('useSubmit', () => {
   });
 
   describe('with Stripe (success)', () => {
-    it('calls onSuccess option with the success result', async () => {
+    it('returns a success result', async () => {
       const mockLoadStripe = loadStripe as jest.MockedFn<typeof loadStripe>;
       const mockCardElement = { name: 'mocked-card-element' };
       const mockStripe = createMockStripe();
@@ -358,20 +352,16 @@ describe('useSubmit', () => {
 
       mockLoadStripe.mockResolvedValue(mockStripe as unknown as Stripe);
 
-      const onError = jest.fn();
-      const onSuccess = jest.fn();
+      let result: SubmissionResult;
 
       function TestForm() {
         const { client } = useFormspree();
-        const handleSubmit = useSubmit('test-formspree-key', {
-          onError,
-          onSuccess,
-        });
+        const handleSubmit = useSubmit('test-formspree-key');
         return (
           <form
-            onSubmit={(event) => {
+            onSubmit={async (event) => {
               event.preventDefault();
-              handleSubmit({
+              result = await handleSubmit({
                 address_line1: 'test-addr-line1',
                 address_line2: 'test-addr-line2',
                 address_city: 'test-addr-city',
@@ -415,9 +405,7 @@ describe('useSubmit', () => {
 
       await userEvent.click(screen.getByRole('button'));
       await waitFor(() => {
-        expect(onError).not.toHaveBeenCalled();
-        expect(onSuccess).toHaveBeenCalledTimes(1);
-        expect(onSuccess).toHaveBeenLastCalledWith({
+        expect(result).toEqual({
           kind: 'success',
           next: 'test-redirect-url',
         });
diff --git a/yarn.lock b/yarn.lock
index 2ace9bd..1ccf501 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3152,17 +3152,17 @@
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
-"@stripe/react-stripe-js@^1.7.1":
-  version "1.16.5"
-  resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.16.5.tgz#51cf862b50ca91ae6193c77a5bec889e81047f10"
-  integrity sha512-lVPW3IfwdacyS22pP+nBB6/GNFRRhT/4jfgAK6T2guQmtzPwJV1DogiGGaBNhiKtSY18+yS8KlHSu+PvZNclvQ==
+"@stripe/react-stripe-js@^3.1.1":
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-3.1.1.tgz#78a2575683637f87c965a81cc1e0f626138f20f1"
+  integrity sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==
   dependencies:
     prop-types "^15.7.2"
 
-"@stripe/stripe-js@^1.35.0":
-  version "1.54.0"
-  resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.54.0.tgz#f92f6b646533776a41bc62b650d2a03f1e282af8"
-  integrity sha512-nElTXkS+nMfDNMkWfLmyeqHQfMGJ1JjrjAVMibV61Oc/rYdUv0cKRYCi1l4ivZ5SySB3vQLcLolxbKBkbNznZA==
+"@stripe/stripe-js@^5.7.0":
+  version "5.7.0"
+  resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.7.0.tgz#ab4ce04a033e8a5bf9cddb9958aa2f85a3c8d22a"
+  integrity sha512-9pCOK3AH75hDKPRyJm9PO5TA3aHZ/PVlIBOZwpi6mABxwr6mMIBjqgZEUThIE5zFEkOmaKXwxBgTsVKC29x+mQ==
 
 "@surma/rollup-plugin-off-main-thread@^2.2.3":
   version "2.2.3"
@@ -3343,33 +3343,31 @@
     "@swc/core-win32-ia32-msvc" "1.3.61"
     "@swc/core-win32-x64-msvc" "1.3.61"
 
-"@testing-library/dom@^9.0.0", "@testing-library/dom@^9.3.0":
-  version "9.3.0"
-  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.0.tgz#ed8ce10aa5e05eb6eaf0635b5b8975d889f66075"
-  integrity sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==
+"@testing-library/dom@^9.3.0":
+  version "9.3.4"
+  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce"
+  integrity sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==
   dependencies:
     "@babel/code-frame" "^7.10.4"
     "@babel/runtime" "^7.12.5"
     "@types/aria-query" "^5.0.1"
-    aria-query "^5.0.0"
+    aria-query "5.1.3"
     chalk "^4.1.0"
     dom-accessibility-api "^0.5.9"
     lz-string "^1.5.0"
     pretty-format "^27.0.2"
 
-"@testing-library/react@^14.0.0":
-  version "14.0.0"
-  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.0.0.tgz#59030392a6792450b9ab8e67aea5f3cc18d6347c"
-  integrity sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==
+"@testing-library/react@^16.2.0":
+  version "16.2.0"
+  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.2.0.tgz#c96126ee01a49cdb47175721911b4a9432afc601"
+  integrity sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==
   dependencies:
     "@babel/runtime" "^7.12.5"
-    "@testing-library/dom" "^9.0.0"
-    "@types/react-dom" "^18.0.0"
 
 "@testing-library/user-event@^14.4.3":
-  version "14.4.3"
-  resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591"
-  integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==
+  version "14.6.1"
+  resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149"
+  integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==
 
 "@tootallnate/once@1":
   version "1.1.2"
@@ -3612,11 +3610,6 @@
   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f"
   integrity sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==
 
-"@types/prop-types@*":
-  version "15.7.5"
-  resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
-  integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
-
 "@types/q@^1.5.1":
   version "1.5.5"
   resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df"
@@ -3632,27 +3625,23 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
-"@types/react-copy-to-clipboard@^5.0.3":
-  version "5.0.4"
-  resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.4.tgz#558f2c38a97f53693e537815f6024f1e41e36a7e"
-  integrity sha512-otTJsJpofYAeaIeOwV5xBUGpo6exXG2HX7X4nseToCB2VgPEBxGBHCm/FecZ676doNR7HCSTVtmohxfG2b3/yQ==
+"@types/react-copy-to-clipboard@^5.0.7":
+  version "5.0.7"
+  resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.7.tgz#0cb724d4228f1c2f8f5675671b3971c8801d5f45"
+  integrity sha512-Gft19D+as4M+9Whq1oglhmK49vqPhcLzk8WfvfLvaYMIPYanyfLy0+CwFucMJfdKoSFyySPmkkWn8/E6voQXjQ==
   dependencies:
     "@types/react" "*"
 
-"@types/react-dom@^18.0.0", "@types/react-dom@^18.0.6":
-  version "18.2.4"
-  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.4.tgz#13f25bfbf4e404d26f62ac6e406591451acba9e0"
-  integrity sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==
-  dependencies:
-    "@types/react" "*"
+"@types/react-dom@^19.0.0":
+  version "19.0.4"
+  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.4.tgz#bedba97f9346bd4c0fe5d39e689713804ec9ac89"
+  integrity sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==
 
-"@types/react@*", "@types/react@^18.0.15":
-  version "18.2.7"
-  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.7.tgz#dfb4518042a3117a045b8c222316f83414a783b3"
-  integrity sha512-ojrXpSH2XFCmHm7Jy3q44nXDyN54+EYKP2lBhJ2bqfyPj6cIUW/FZW/Csdia34NQgq7KYcAlHi5184m4X88+yw==
+"@types/react@*", "@types/react@^19.0.0":
+  version "19.0.10"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.10.tgz#d0c66dafd862474190fe95ce11a68de69ed2b0eb"
+  integrity sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==
   dependencies:
-    "@types/prop-types" "*"
-    "@types/scheduler" "*"
     csstype "^3.0.2"
 
 "@types/resolve@1.17.1":
@@ -3667,11 +3656,6 @@
   resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
   integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
 
-"@types/scheduler@*":
-  version "0.16.3"
-  resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
-  integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
-
 "@types/semver@^6.0.0":
   version "6.2.3"
   resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.2.3.tgz#5798ecf1bec94eaa64db39ee52808ec0693315aa"
@@ -4293,7 +4277,7 @@ argparse@^2.0.1:
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
   integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
 
-aria-query@^5.0.0, aria-query@^5.1.3:
+aria-query@5.1.3, aria-query@^5.1.3:
   version "5.1.3"
   resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e"
   integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==
@@ -8899,7 +8883,7 @@ log-update@^4.0.0:
     slice-ansi "^4.0.0"
     wrap-ansi "^6.2.0"
 
-loose-envify@^1.1.0, loose-envify@^1.4.0:
+loose-envify@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
   integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -10441,13 +10425,12 @@ react-dev-utils@^12.0.1:
     strip-ansi "^6.0.1"
     text-table "^0.2.0"
 
-react-dom@^18.2.0:
-  version "18.2.0"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
-  integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
+react-dom@^19.0.0:
+  version "19.0.0"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57"
+  integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==
   dependencies:
-    loose-envify "^1.1.0"
-    scheduler "^0.23.0"
+    scheduler "^0.25.0"
 
 react-error-overlay@^6.0.11:
   version "6.0.11"
@@ -10461,10 +10444,10 @@ react-google-recaptcha-v3@^1.10.1:
   dependencies:
     hoist-non-react-statics "^3.3.2"
 
-react-hook-form@7.49.2:
-  version "7.49.2"
-  resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.49.2.tgz#6fb2742e1308020f26cb1915c7012b6c07b11ade"
-  integrity sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA==
+react-hook-form@^7.54.2:
+  version "7.54.2"
+  resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211"
+  integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==
 
 react-is@^16.13.1, react-is@^16.7.0:
   version "16.13.1"
@@ -10541,12 +10524,10 @@ react-scripts@5.0.1:
   optionalDependencies:
     fsevents "^2.3.2"
 
-react@^18.2.0:
-  version "18.2.0"
-  resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
-  integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
-  dependencies:
-    loose-envify "^1.1.0"
+react@^19.0.0:
+  version "19.0.0"
+  resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd"
+  integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==
 
 read-cache@^1.0.0:
   version "1.0.0"
@@ -10911,12 +10892,10 @@ saxes@^6.0.0:
   dependencies:
     xmlchars "^2.2.0"
 
-scheduler@^0.23.0:
-  version "0.23.0"
-  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
-  integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
-  dependencies:
-    loose-envify "^1.1.0"
+scheduler@^0.25.0:
+  version "0.25.0"
+  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015"
+  integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==
 
 schema-utils@2.7.0:
   version "2.7.0"


Reviews:
user: login: colevscode
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T21:47:34Z

Review comments:

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-02T08:25:35Z
body: ###  🦋  Changeset detected

Latest commit: a9b02fec26ee4ec2a48ed3c4afaa38accddef8d9

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name             | Type  |
| ---------------- | ----- |
| @formspree/react | Major |
| @formspree/core  | Major |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/formspree/formspree-js/new/support-react-19?filename=.changeset/real-phones-study.md&value=---%0A%22%40formspree%2Fcore%22%3A%20patch%0A%22%40formspree%2Freact%22%3A%20patch%0A%22%40formspree%2Fcra-demo%22%3A%20patch%0A---%0A%0ASupport%20React%2019%0A)


author_association: NONE
user: login: vercel[bot]
created_at: 2025-03-02T08:25:36Z
body: [vc]: #6MMMpMPehq37Taeh6Zt0ubhTefx9XhqvPqzzXM7KCPg=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJmb3Jtc3ByZWUtcmVhY3QtZGVtbyIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9mb3Jtc3ByZWUvZm9ybXNwcmVlLXJlYWN0LWRlbW8vOHlQTEhGZjVWNFh2V2pjcUZjVlg1Y2NkZHliWiIsInByZXZpZXdVcmwiOiJmb3Jtc3ByZWUtcmVhY3QtZGVtby1naXQtc3VwcG9ydC1yZWFjdC0xOS1mb3Jtc3ByZWUudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJmb3Jtc3ByZWUtcmVhY3QtZGVtby1naXQtc3VwcG9ydC1yZWFjdC0xOS1mb3Jtc3ByZWUudmVyY2VsLmFwcCJ9LCJyb290RGlyZWN0b3J5IjoiZXhhbXBsZXMvY3JhLWRlbW8ifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **formspree-react-demo** | ✅ Ready ([Inspect](https://vercel.com/formspree/formspree-react-demo/8yPLHFf5V4XvWjcqFcVX5ccddybZ)) | [Visit Preview](https://formspree-react-demo-git-support-react-19-formspree.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/formspree-react-demo-git-support-react-19-formspree.vercel.app?via=pr-comment-feedback-link) | Mar 13, 2025 3:46am |


author_association: NONE